A maioria das equipes de agentes não constrói um arcabouço. Elas adotam um. LangChain, LangGraph, OpenAI Agents SDK, Anthropic SDK, CrewAI, AutoGen, o loop, as ferramentas, a memória e a orquestração são escolhidos como uma única decisão. O arcabouço é um framework que você importa. Se algo nele não se encaixa, você faz um fork, briga com ele ou dá um jeito de contornar.

Eu acredito que essa abordagem está errada, e é por isso que toda equipe de agentes de longo prazo acaba reescrevendo seu arcabouço do zero. O arcabouço não é uma coisa só. São dez ou doze coisas diferentes agrupadas porque o ecossistema ao redor não oferece uma maneira de compô-las. Os pacotes do Pi agent estão no caminho certo, mas ainda estão no paradigma de "Adicione outro serviço e integre-o com todos os outros". O motor iii trata todos os workers da mesma forma e elimina completamente a lógica de integração. O roteador de provedores, o cofre de credenciais, o motor de políticas, o portal de aprovação, o catálogo de modelos, o armazenamento de sessão, o rastreador de orçamento, o fanout de hooks pós-chamada e o loop de turno durável são preocupações independentes. Todos são interoperáveis com sua fila, servidor HTTP/API, streaming e até mesmo workers de navegador. Um framework que os entrega como um bloco único está te vendendo uma troca que você não precisava fazer.
A aposta por trás do iii é que eles não deveriam ser um bloco único. Deveria haver um conjunto de workers em um motor compartilhado, cada um substituível, cada um versionado independentemente, cada um conectado por um único primitivo: um gatilho (iii.trigger()) que todos os outros workers também usam. O arcabouço se torna uma pilha de workers instaláveis, e "construa o seu próprio" deixa de significar "faça um fork de um framework". Passa a significar "troque alguns workers".
Este post mostra como isso funciona na prática. A pilha completa que impulsiona um turno de agente iii hoje, por que cada camada é seu próprio worker e como substituir qualquer um deles.
Os 15 trabalhos que um arcabouço de agente precisa fazer
Se você reduzir um arcabouço de agente de produção às suas responsabilidades, obtém uma lista mais ou menos assim:
- Aceitar uma solicitação de turno de um cliente e persistir
- Resolver credenciais para o provedor de modelo que será chamado
- Consultar o que o modelo escolhido pode realmente fazer (visão, ferramentas, streaming, janela de contexto)
- Conduzir a máquina de estado por turno, provisionar, transmitir assistente, executar ferramentas, direcionar, encerrar
- Carregar e servir corpos de skill que descrevem a forma da requisição, códigos de erro e notas de uso de cada função
- Montar o prompt do sistema, parágrafo de modo, preâmbulo de identidade, diretório de trabalho e apêndice de skills padrão
- Transmitir tokens de volta ao cliente conforme o modelo os produz
- Verificar cada chamada de ferramenta (que é apenas uma função) contra uma política antes de executar
- Pausar chamadas de ferramenta que precisam de decisão humana e rotear a resposta de volta ao turno correto
- Rastrear gastos com LLM contra orçamentos por workspace ou por agente
- Executar hooks antes e depois das chamadas de ferramenta (logging, redação, efeitos colaterais personalizados)
- Persistir a sessão como uma árvore ramificada para que forks e retomadas funcionem
- Compactar o histórico da sessão quando a janela de contexto se enche
- Emitir um fluxo de eventos ao qual a UI se inscreve
- Peça que falta em toda construção de empresa de agente que vejo. Carregar um trace OpenTelemetry em cada etapa para que você possa depurar
Todo arcabouço de agente sério faz a maioria dessas tarefas. Os caros fazem todas. Os baratos cortam custos e reconstroem os cantos mais tarde quando chegam à produção. Os frameworks os agrupam em um monólito e enviam uma versão de cada. Essa última parte é a que te custa caro, porque um ano depois, você descobre que o motor de política que você quer não é o motor de política que o framework entrega, e substituí-lo significa substituir o arcabouço.
O arcabouço iii entrega cada um desses treze trabalhos como um worker separado no registro workers.iii.dev. Cada um fala o mesmo protocolo WebSocket. Cada um registra funções e gatilhos no mesmo barramento do motor. Cada um pode ser adicionado, trocado e escrito em qualquer linguagem com um SDK via iii worker add.
A pilha, por worker
Aqui está a pilha de produção real do monorepo iii-hq/workers, com o trabalho de cada worker em uma linha. O pacote completo está em github.com/iii-hq/workers/harness:

Onze workers. Um motor. Cada um em uma versão publicada. Cada um executável de forma independente como um processo autônomo (pnpm dev:<worker> em desenvolvimento, iii worker add <worker-específico> como binário de lançamento) ou como parte do ponto de entrada composto que os inicia juntos.
A razão pela qual isso importa: cada caixa naquela tabela é um lugar onde alguém pode te entregar um worker diferente, e você mantém o resto. Não gosta do catálogo de modelos estático? Conecte um worker que registra models::list e lê de uma API ativa. Não gosta de credenciais baseadas em arquivo? Conecte um worker que registra auth::get_token e lê de um gerenciador de segredos. Quer uma FSM de turno diferente para um fluxo de trabalho que ramifica de outra forma? Substitua turn-orchestrator, todos os dependentes chamam run::start e leem turn_state através do mesmo barramento, então o resto da pilha não muda.
Como o loop realmente funciona
A forma de um turno é assim, percorrendo os workers na ordem em que eles são acionados.
Um navegador/CLI/chat faz POST de um turno através de harness::trigger com {session_id, message_id, payload}. O meta-worker do arcabouço encaminha o payload para run::start. Esse salto existe para que o wrapper de span do OpenTelemetry possa propagar os IDs de sessão e mensagem como baggage, que se propaga para cada chamada iii.trigger aninhada em todos os workers da pilha. A árvore de trace do outro lado é um grafo conectado.
run::start chega ao turn-orchestrator. Ele persiste a solicitação de execução, inicializa o TurnStateRecord inicial no estado iii em session/<sid>/turn_state e retorna imediatamente. O trabalho real acontece dentro da máquina de estado durável por turno, ativada por publicações na FIFO turn-step.
Os dois estados terminais são stopped (saída limpa via finishSession()) e failed (um lançamento inesperado de handler cai aqui, confirma a fila para parar de tentar novamente, e exibe message_complete{stop_reason:'error'} mais agent_end para que a UI mostre o motivo). O encerramento é uma porta finishSession() embutida chamada de qualquer caminho de fim de turno, não uma etapa enfileirada separada.
provisioning faz três coisas. Ele inicia uma microVM iii-sandbox se a execução precisar de isolamento. Ele chama directory::skills::download para cada namespace em system_default_skills (padrão ["iii://iii-directory/index"]) para que o iii-directory pré-cacheie os corpos de skill com os quais a execução começa. E ele monta o prompt do sistema em três camadas: um parágrafo de modo escolhido de run_request.mode (plan, ask ou agent), o preâmbulo de identidade iii que ensina ao modelo a convenção agent_trigger e o padrão de descoberta on-demand directory::skills::get, e um índice anexado das skills padrão com as quais o agente inicia. O chamador pode substituir o prompt inteiro passando system_prompt em run::start; caso contrário, o orquestrador o constrói. Os schemas das funções vêm do catálogo ativo do motor.
assistant_streaming chama provider::<name>::stream no worker provedor que corresponde ao campo provider da execução. O worker provedor puxa credenciais via auth::get_token (auth-credentials), transmite a resposta SSE do modelo para um canal iii, e o orquestrador drena esse canal emitindo eventos message_update em agent::events para o fanout da UI. A criação do canal e o loop de leitura ficam atrás de um MessagePump baseado em pull em provider-stream.ts, para que o estado de streaming permaneça focado nas transições.
Quando o assistente retorna chamadas de ferramenta, a FSM entra em function_execute. Cada chamada de ferramenta passa por dispatchWithHook, o único ponto de gargalo no orquestrador. consultBefore chama policy::check_permissions diretamente com um timeout de 5 segundos. O worker de política (o meta-worker do arcabouço, na pilha padrão) lê iii-permissions.yaml, compara o function_id da chamada com o conjunto de regras e retorna um de três resultados:
allow: o despacho prossegue; o orquestrador aciona a função alvo e escreve o resultadodeny: o despacho é interrompido com umDenialEnvelope, o resultado se torna um registro de negaçãoneeds_approval: a chamada individual estaciona na listaawaiting_approvaldo turno. O resto do lote continua despachando. O turno transita parafunction_awaiting_approvalapenas quando uma ou mais entradas estão pendentes.
A ativação de aprovação é reativa e compartilhada. O orquestrador registra exatamente um gatilho de estado turn::on_approval no escopo approvals. Quando o console chama approval::resolve, o worker approval-gate escreve approvals/<sid>/<cid> = {decision, reason} no estado iii. Essa escrita dispara turn::on_approval, que avança a sessão afetada. function_awaiting_approval lê apenas as decisões que acabaram de chegar, despacha cada uma à medida que chega (allow se torna um despacho pré-aprovado, deny ou aborted se torna uma negação sintética), e avança quando awaiting_approval[] está vazio. Nenhuma função de retomada por chamada para registrar. Nenhuma re-varredura de inicialização para recuperar aprovações pendentes. Um gatilho cobre cada sessão.
Falha fechada por construção: se o worker de política estiver inacessível ou o timeout de 5 segundos disparar, consultBefore nega a chamada com um envelope gate_unavailable. Se o próprio iii::durable::publish falhou, o fanout do hook retorna publish_failed: true e o orquestrador trata como uma negação.
Alguns ganhos de latência surgem dessa forma. O hook pós-chamada de função ignora publish_collect via um cache de presença de assinante quando nenhum assinante durável está registrado para o tópico, removendo aproximadamente 500ms por chamada de função executada. tearing_down é embutido em finishSession(), removendo um salto de fila durável por turno. context-compaction assina um fluxo dedicado agent::turn_end que o orquestrador emite nos limites do turno, então as ativações do compactador são por turno em vez de por evento. O gatilho de estado do fanout de criação de sessão é fechado apenas por escopo e corresponde em processo, então o RPC anterior harness::session::is_create_event por escrita desapareceu.
Após o lote ser concluído, steering_check decide se continua, para ou atinge max_turns. Se continuar, volta para assistant_streaming. Se parar ou max, finishSession() é executado embutido: emite agent_end, libera o sandbox, transita para stopped.
Durante toda a execução, todo worker que participa emite spans OTel marcados com iii.session.id, iii.message.id e iii.function.id. Essas tags são o que o engine::traces::group_by do motor lê para popular "Group by Session" / "Group by Message" / "Group by Function" na UI de traces. A instrumentação é automática: src/runtime/worker.ts envolve cada registerFunction em um Proxy para que nenhum código por worker precise lembrar de adicionar spans.
Construa o seu próprio
A parte interessante é que nenhum dos workers acima é especial. Cada um é um processo que abre um WebSocket para o motor, registra algumas funções e gatilhos e executa. O contrato é o mesmo que o contrato que todo worker de aplicação usa. O arcabouço é construído sobre o mesmo primitivo que sua lógica de negócios é construída.
O que significa que "construa seu próprio arcabouço" se decompõe na mesma operação que "escreva qualquer worker." Você escolhe a camada que deseja substituir, escreve um worker que registra as mesmas funções no barramento, executa iii worker add, e o resto da pilha começa a usar seu worker.
Duas camadas não aparecem na tabela de workers acima, mas importam para o comportamento do arcabouço. Skills são como cada worker anuncia o que suas funções fazem. Todo worker pode publicar uma skill em iii://<worker>/<function> que o agente busca via directory::skills::get antes de chamar essa função pela primeira vez. O prompt do sistema é montado por turno a partir de um parágrafo de modo, o preâmbulo de identidade iii e os corpos de skill padrão com os quais a execução foi configurada. Ambos são orientados por barramento: skills são servidas pelo worker iii-directory, o prompt do sistema é montado pelo turn-orchestrator. Ambos são substituíveis.
Cinco exemplos concretos.
Substitua o catálogo de modelos por uma API ativa. Escreva um worker que registra models::list, models::get, models::supports. Faça com que ele busque no endpoint do catálogo do seu provedor a cada N minutos e armazene em cache. Publique. iii worker add your-org/dynamic-models-catalog. Pare o worker models-catalog estático. O turn-orchestrator nunca percebe a diferença. Ele chama iii.trigger('models::list') e o motor roteia para o worker que registrou esse id de função mais recentemente.
Adicione um novo provedor. A forma já é comprovada por provider-kimi e provider-lmstudio. Cada um é um worker que registra provider::<name>::stream e provider::<name>::complete, drena um fluxo SSE da API upstream para um canal iii e escreve seu uso de modelo para llm-budget via budget::record. Adicionar um quinto provedor é escrever uma pasta com um iii.worker.yaml e um register.ts. Publique no registro ou mantenha local. O turn-orchestrator escolhe o provedor pelo campo provider da execução; novos provedores ficam disponíveis no instante em que o worker se conecta.
Sirva skills de um repositório de artefatos privado. Escreva um worker que registra directory::skills::get e directory::skills::list, apoiado pelo seu sistema de docs interno ou um bucket S3 privado. Desconecte ou renomeie o worker iii-directory padrão. A inicialização do orquestrador chama directory::skills::download por namespace; seu worker responde. O padrão "buscar a skill por função antes de chamar uma nova função" do agente continua funcionando inalterado porque a forma do fio é a mesma.
Substitua o prompt do sistema completamente. run::start aceita um campo opcional system_prompt. Passe-o e o orquestrador usa sua string exatamente como fornecida, pulando a montagem do parágrafo de modo + preâmbulo de identidade + apêndice de skills. Útil quando você tem um ativo de prompt existente que deseja que o arcabouço honre sem modificação. O download de skills ainda é executado na inicialização, então o agente mantém a descoberta on-demand directory::skills::get mesmo com um prompt personalizado.
Substitua a superfície da UI do portal de aprovação. O worker approval-gate padrão registra approval::resolve. O schema do fio é uma chamada de função:
O handler persiste approvals/<sid>/<cid> = {decision, reason} no estado iii. O único gatilho de estado turn::on_approval do orquestrador capta essa escrita e ativa a sessão correta. Se você quiser conduzir aprovações do Slack em vez do console, escreva um worker do Slack que escuta comandos de barra /approve <id> e /deny <id>, depois chama approval::resolve com o payload correto. O orquestrador nunca percebe a diferença. Todo o worker approval-gate permanece intocado. Você adicionou um novo worker; não substituiu o existente.
Se você quiser um motor de política diferente (OPA, Cedar, sua própria DSL), escreva um worker que registra policy::check_permissions e retorna { decision, rule_id?, matched_constraint? }. Desconecte o worker de política padrão (que está encapsulado dentro do meta-worker do arcabouço, então você desabilitaria esse handler ou executaria um meta-worker simplificado). O consultBefore do turn-orchestrator não percebe a diferença. Mesmo timeout de 5 segundos, mesma semântica de falha fechada, mesma forma de fio.
O objetivo desses exemplos não são as substituições específicas. É a forma da operação. Toda camada do arcabouço na pilha iii é acessível através de um ou dois IDs de função no barramento. Substituir uma camada é escrever um worker que registra esses IDs. O resto do sistema permanece.
O arcabouço é um controle deslizante, não uma bifurcação na estrada
O debate clássico sobre arcabouços se coloca como fino vs. grosso. O loop fino da Anthropic versus o DAG explícito do LangGraph. O enquadramento assume que você escolhe um lado e vive com ele.
Quando o arcabouço é composto de workers no mesmo barramento, fino vs. grosso é apenas uma contagem de quantos workers você instala. Um arcabouço fino é turn-orchestrator mais provider-anthropic mais auth-credentials mais um meta-worker de arcabouço mínimo. Só isso. Sem aprovações, sem orçamentos, sem motor de política, sem fanout de hooks. Execute qualquer coisa. Confie no modelo. Útil para agentes de pesquisa autônomos, loops experimentais, qualquer coisa interna.
Um arcabouço grosso é todos os treze workers mais context-compaction mais um worker de política personalizado mais um approval-gate personalizado mais uma superfície de aprovação integrada ao Slack mais o worker de orçamento impondo limites por workspace. Útil para um agente executando fluxos de trabalho do cliente onde cada chamada de ferramenta precisa ser auditável e cada gasto de modelo precisa ser consolidado em um painel financeiro.
A distância arquitetural entre fino e grosso não é uma reescrita. É uma mudança de configuração. Mesmo protocolo de fio, mesma forma de trace, mesma história de observabilidade. O controle deslizante se move adicionando e removendo workers do seu config.yaml. Tudo o resto se mantém.
Isso também se aplica dentro de um único worker. O turn-orchestrator acabou de lançar uma refatoração que colapsou sua FSM de onze estados para sete, eliminou o mecanismo turn::approval_resume::<sid>/<cid> por chamada em favor de um único gatilho de estado reativo turn::on_approval no escopo approvals, e embutiu tearing_down em uma porta finishSession(). Todos os outros workers na pilha (approval-gate, session, llm-budget, providers, models-catalog, auth-credentials, hook-fanout, context-compaction) permaneceram inalterados. A forma de fio de approval::resolve não mudou. Os contratos se mantiveram. Essa é a propriedade que a composição te dá: uma grande reescrita interna de um worker é uma mudança autocontida porque cada vizinho se comunica com ele através de IDs de função no nível do barramento.
Esta é a parte que o modelo de framework não pode te dar. Um framework escolhe uma posição no controle deslizante para você e te prende nela. O modelo de worker deixa o controle deslizante em sua mão.
O que isso significa na prática
Se você tem executado um agente sobre um framework e sentindo os mesmos problemas de limite que a maioria das equipes enfrenta em escala, a resposta provavelmente não é "reescrever o arcabouço em nosso próprio framework." O motor de política não se estende da maneira que você precisa. A UI de aprovação está conectada à superfície de chat do framework. O armazenamento de credenciais não consegue falar com seu gerenciador de segredos. O rastreador de orçamento está em um banco de dados sidecar que o trace não consegue ver. A resposta é migrar para um substrato onde o arcabouço é decomposto desde o início.
A maneira mais rápida de sentir o argumento é clonar github.com/iii-hq/workers, pnpm install, pnpm build e executar o ponto de entrada composto. Você obterá o arcabouço completo de quatorze workers apontado para um motor iii. Você pode desabilitar qualquer worker removendo sua entrada da lista de inicialização. Você pode trocar qualquer worker escrevendo um substituto que registra os mesmos IDs de função. Você pode estender qualquer worker adicionando um assinante aos seus tópicos de hook. hook-fanout::publish_collect é o genérico no qual todo hook iii é construído.
A documentação está em iii.dev/docs. O motor está em github.com/iii-hq/iii. O registro de workers está em workers.iii.dev. O pacote do arcabouço está em github.com/iii-hq/workers/harness.
A aposta
Um arcabouço não é algo que você instala. Um arcabouço é um conjunto de trabalhos que seu sistema precisa realizar para que um agente execute de forma durável, segura e observável. A era dos frameworks agrupou esses trabalhos porque nada subjacente oferecia uma maneira de compô-los.
A aposta do iii é que um primitivo — um worker que se conecta ao motor via WebSocket e registra funções e gatilhos — é pequeno o suficiente para absorver cada um desses trabalhos separadamente, e que a pilha resultante é mais útil do que qualquer framework porque cada camada é independentemente substituível.
Você não adota o arcabouço iii. Você instala os workers que deseja, escreve os que precisa e termina com um arcabouço moldado exatamente para o seu sistema. Mesmo protocolo em cada camada. Mesmo trace em cada chamada. Mesmo iii worker add para as partes que você pega do registro quanto para as partes que você publica.
É assim que "construa seu próprio arcabouço de agente" se parece quando o substrato tem a forma certa. Escolha os workers. Escreva os que faltam. Componha. O arcabouço é a composição.
Junte-se a nós na construção do arcabouço de agente perfeito que o mundo moderno precisa: discord.gg/iiidev
iii é open source. Comece em iii.dev/docs. Os workers do arcabouço estão em github.com/iii-hq/workers e o motor está em github.com/iii-hq/iii.
— Mike Piccolo, Founder & CEO @iiidevs





