A maioria das equipes de agentes não constroi um harness. 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 da prateleira como uma decisão única. O harness é um framework que você importa. Se algo dentro dele não se encaixa, você faz um fork, luta contra ele ou encontra uma forma de contorná-lo.

Acho que essa forma está errada, e é por isso que toda equipe de agentes de longo prazo acaba reescrevendo seu harness do zero. O harness 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 Pi agent estão no caminho certo, mas ainda estão no paradigma de "Adicionar outro serviço e integrá-lo com todos os outros." O mecanismo iii trata todos os workers da mesma forma e remove a lógica de integração completamente. O roteador de provedores, o cofre de credenciais, o mecanismo de políticas, o gate 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 durável de turnos 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á 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 mecanismo compartilhado, cada um substituível, cada um versionado independentemente, cada um conectado por um único primitivo: um trigger (iii.trigger()) que todos os outros workers também usam. O harness se torna uma pilha de workers instaláveis, e "construa o seu próprio" deixa de significar "fazer fork de um framework." Passa a significar "trocar alguns workers."
Este post mostra como isso realmente funciona. 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 harness de agente precisa fazer
Se você reduzir um harness 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-la
- 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, desmontar
- Carregar e servir os corpos de skill que descrevem a forma da solicitação de cada função, códigos de erro e notas de uso
- 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 executá-la
- Pausar chamadas de ferramenta que precisam de uma decisão humana e rotear a resposta de volta ao turno correto
- Rastrear gastos de 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 depurá-lo
Todo harness de agente sério faz a maioria dessas coisas. Os caros fazem todas. Os baratos cortam caminhos e reconstroem esses caminhos depois quando chegam à produção. Os frameworks os agrupam em um monólito e enviam uma versão de cada. Essa última parte é a que custa caro, porque um ano depois, você descobre que o mecanismo de políticas que você quer não é o mecanismo de políticas que o framework entrega, e substituí-lo significa substituir o harness.
O harness 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 triggers no mesmo barramento do mecanismo. Cada um é adicionável via iii worker add, substituível e escrevível em qualquer linguagem com um SDK.
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 mecanismo. Cada um está em uma versão publicada. Cada um é executável de forma independente como um processo autônomo (pnpm dev:<worker> em dev, iii worker add <worker-específico> como um binário de release) ou como parte do ponto de entrada composto que os inicia juntos.
A razão pela qual isso importa: cada caixa nessa tabela é um lugar onde alguém pode lhe 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 um FSM de turno diferente para um fluxo de trabalho que ramifica de forma diferente? Substitua turn-orchestrator, cada dependente chama run::start e lê 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 são acionados.
Um navegador/CLI/chat faz um POST de um turno através de harness::trigger com {session_id, message_id, payload}. O worker meta do harness encaminha o payload para run::start. Esse salto existe para que o wrapper de span OpenTelemetry possa semear 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, semeia 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, despertada por publicações no 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 que pare de tentar novamente e exibe message_complete{stop_reason:'error'} mais agent_end para que a UI mostre o motivo). A desmontagem é uma porta finishSession() inline chamada de qualquer caminho de fim de turno, não uma etapa enfileirada separada.
provisioning faz três coisas. Inicializa uma microVM iii-sandbox se a execução precisar de isolamento. Chama directory::skills::download para cada namespace em system_default_skills (padrão ["iii://iii-directory/index"]) para que o iii-directory pré-armazene em cache os corpos de skill com os quais a execução começa. E 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 sob demanda directory::skills::get, e um índice anexado das skills padrão com as quais o agente inicializa. O chamador pode substituir o prompt inteiro passando system_prompt em run::start; caso contrário, o orquestrador o constrói. Os esquemas de função vêm do catálogo ativo do mecanismo.
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 em 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 vivem 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, o 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 worker meta do harness, na pilha padrão) lê iii-permissions.yaml, corresponde o function_id da chamada ao conjunto de regras e retorna um de três resultados:
- allow: o despacho prossegue; o orquestrador aciona a função alvo e escreve o resultado
- deny: o despacho é interrompido com um DenialEnvelope, o resultado se torna um registro de negação
- needs_approval: a chamada individual é estacionada na lista awaiting_approval do turno. O resto do lote continua despachando. O turno transita para function_awaiting_approval somente quando uma ou mais entradas estão pendentes.
O despertar de aprovação é reativo e compartilhado. O orquestrador registra exatamente um trigger 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 conforme 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 trigger cobre todas as sessões.
Fail-closed por construção: se o worker de política está inacessível ou o timeout de 5 segundos dispara, 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 emergem dessa forma. O hook pós-chamada de função faz short-circuit em 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 se inscreve em um fluxo dedicado agent::turn_end que o orquestrador emite nos limites do turno, então os despertares do compactador são por turno em vez de por evento. O trigger de estado session-create fanout é limitado apenas pelo escopo e corresponde em processo, então o RPC harness::session::is_create_event anterior por escrita desapareceu.
Após o lote ser concluído, steering_check decide se deve continuar, parar ou atingir max_turns. Se continuar, volta para assistant_streaming. Se parar ou max, finishSession() é executado inline: emite agent_end, libera a 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 mecanismo 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 mecanismo, registra algumas funções e triggers e executa. O contrato é o mesmo que o contrato que todo worker de aplicação usa. O harness é construído sobre o mesmo primitivo que sua lógica de negócios é construída.
O que significa que "construir seu próprio harness" se decompõe na mesma operação que "escrever 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 como o harness se comporta. 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 pelo 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-o. 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 mecanismo roteia para o worker que registrou esse ID de função mais recentemente.
Adicione um novo provedor. A forma é que provider-kimi e provider-lmstudio já comprovam isso. Cada um é um worker que registra provider::<name>::stream e provider::<name>::complete, drena um stream SSE da API upstream em um canal iii e escreve seu uso de modelo em 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 se tornam 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 interno de documentos 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 do agente "buscar a skill por função antes de chamar uma nova função" continua funcionando inalterado porque a forma no fio é a mesma.
Substitua o prompt do sistema inteiramente. run::start aceita um campo opcional system_prompt. Passe-o e o orquestrador usa sua string literalmente, 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 harness honre sem modificação. O download de skill ainda é executado na inicialização, então o agente mantém a descoberta sob demanda directory::skills::get mesmo com um prompt personalizado.
Substitua a superfície da UI do gate de aprovação. O worker approval-gate padrão registra approval::resolve. O esquema no fio é uma chamada de função:
O handler persiste approvals/<sid>/<cid> = {decision, reason} no estado iii. O único trigger de estado turn::on_approval do orquestrador capta essa escrita e desperta a sessão correta. Se você quiser conduzir aprovações do Slack em vez do console, escreva um worker do Slack que ouve comandos de barra /approve <id> e /deny <id>, então 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 mecanismo de políticas diferente (OPA, Cedar, seu próprio 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 worker meta do harness, 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 fail-closed, mesma forma no fio.
O objetivo desses exemplos não são as substituições específicas. É a forma da operação. Toda camada do harness 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 harness é um slider, não uma bifurcação na estrada
O debate clássico sobre harness se enquadra como thin vs thick. O loop thin da Anthropic versus o DAG explícito do LangGraph. O enquadramento assume que você escolhe um lado e vive com ele.
Quando o harness é composto de workers no mesmo barramento, thin vs thick é apenas uma contagem de quantos workers você instala. Um harness thin é turn-orchestrator mais provider-anthropic mais auth-credentials mais um worker meta de harness mínimo. Só isso. Sem aprovações, sem orçamentos, sem mecanismo de políticas, sem fanout de hooks. Execute qualquer coisa. Confie no modelo. Útil para agentes de pesquisa autônomos, loops experimentais, qualquer coisa interna.
Um harness thick são 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 aplicando limites por workspace. Útil para um agente executando fluxos de trabalho de 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 thin e thick não é uma reescrita. É uma mudança de configuração. Mesmo protocolo no fio, mesma forma de trace, mesma história de observabilidade. O slider se move adicionando e removendo workers do seu config.yaml. Tudo o resto se mantém.
Isso se aplica também dentro de um único worker. O turn-orchestrator acabou de lançar uma refatoração que colapsou seu FSM de onze estados para sete, deletou o mecanismo turn::approval_resume::<sid>/<cid> por chamada em favor de um trigger de estado turn::on_approval reativo 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 no fio de approval::resolve não mudou. Os contratos se mantiveram. Essa é a propriedade que a composição lhe 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 lhe dar. Um framework escolhe uma posição no slider para você e o prende nela. O modelo de worker deixa o slider 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 harness em nosso próprio framework." O mecanismo de políticas 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 pode falar com seu gerenciador de segredos. O rastreador de orçamento está em um banco de dados sidecar que o trace não pode ver. A resposta é mudar para um substrato onde o harness é 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 harness completo de quatorze workers apontado para um mecanismo 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 sobre o qual todo hook iii é construído.
A documentação está em iii.dev/docs. O mecanismo está em github.com/iii-hq/iii. O registro de workers está em workers.iii.dev. O pacote do harness está em github.com/iii-hq/workers/harness.
A aposta
Um harness não é algo que você instala. Um harness é um conjunto de trabalhos que seu sistema precisa fazer para que um agente execute de forma durável, segura e observável. A era dos frameworks agrupou esses trabalhos porque nada abaixo lhe dava uma maneira de compô-los.
A aposta do iii é que um primitivo — um worker que se conecta ao mecanismo via WebSocket e registra funções e triggers — é pequeno o suficiente para absorver cada um desses trabalhos separadamente, e que a pilha resultante é mais útil do que qualquer framework porque toda camada é independentemente substituível.
Você não adota o harness iii. Você instala os workers que deseja, escreve os que precisa e acaba com um harness com a forma exata do seu sistema. Mesmo protocolo em toda camada. Mesmo trace em toda chamada. Mesmo iii worker add para as partes que você pega do registro quanto para as partes que você publica você mesmo.
É assim que "construir seu próprio harness de agente" se parece quando o substrato tem a forma certa. Escolha os workers. Escreva os que faltam. Componha. O harness é a composição.
Junte-se a nós na construção do harness de agente perfeito que o mundo moderno precisa: discord.gg/iiidev
iii é open source. Comece em iii.dev/docs. Os workers do harness estão em github.com/iii-hq/workers e o mecanismo está em github.com/iii-hq/iii.
— Mike Piccolo, Fundador & CEO @iiidevs





