Skip to main content
Un modelo por sí solo no puede decirte qué hay ahora mismo en la portada de Hacker News. Para eso necesita herramientas, y alguien tiene que construir y mantener esas herramientas. Apify ya lo hizo: aloja miles de Actors que raspan sitios, rastrean documentación y extraen datos estructurados, y los expone a través del Model Context Protocol. Esa combinación encaja bien con Venice. Venice aporta function calling compatible con OpenAI sin retención de datos, Apify aporta las herramientas, y MCP es el formato de cable entre ambos. No escribes un scraper por sitio: te conectas una vez y dejas que el modelo elija el Actor. En este tutorial construiremos un agente de terminal en Python que hace exactamente eso. Al final tendrás un CLI que descubre un modelo de Venice con function calling en tiempo de ejecución, carga el catálogo de herramientas de Apify por MCP, transmite las respuestas en streaming a tu terminal y pregunta antes de gastar dinero en una ejecución de un Actor. ¿Te interesa la implementación completa del código? Echa un vistazo a el repositorio de GitHub. Antes de continuar, necesitarás una clave de API de Venice:

Qué vamos a construir

La implementación de referencia es un paquete pequeño de Python con una tarea por módulo: Una sola pregunta fluye a través de él así:
  1. Pide a Venice el modelo actual con function calling, salvo que hayas fijado uno.
  2. Conéctate al servidor MCP de Apify y lista sus herramientas.
  3. Reescribe esas herramientas MCP como definiciones de función compatibles con OpenAI.
  4. Envía la pregunta con la lista de herramientas adjunta.
  5. Si el modelo devuelve tool_calls, ejecútalos contra Apify y añade los resultados como mensajes tool.
  6. Repite hasta que el modelo responda con texto en lugar de una llamada a herramienta.
Los pasos 4 a 6 son todo el agente. Todo lo demás existe para que esos tres pasos sean seguros y agradables de usar.
Este agente puede gastar cómputo de Apify en tu cuenta. Empieza sin APIFY_TOKEN si solo quieres herramientas de búsqueda y documentación, y deja --yes desactivado hasta que realmente tengas la intención de ejecutar Actors.

Configurar el proyecto

El proyecto de referencia usa Python 3.12+ y uv. Crea un proyecto nuevo:
Instala las dependencias:
Eso es httpx2, la línea 2.x de httpx, de la que tanto openai como mcp ya dependen. Instalarlo directamente evita acabar con dos clientes HTTP en el mismo entorno. Después crea un archivo .env:
VENICE_API_KEY viene de la configuración de la API de Venice. APIFY_TOKEN viene de la Consola de Apify y es opcional — enseguida veremos qué obtienes sin él.

Cargar la configuración

La configuración va primero porque todos los demás módulos la reciben como argumento. Usaremos pydantic-settings para que las variables de entorno, .env y las banderas del CLI acaben en un único objeto validado. En src/venice_terminal_agent/config.py, una clase Settings(BaseSettings) lleva los campos que importan:
Dos de estos campos llevan decisiones en lugar de valores por defecto. venice_model es None en lugar de un ID de modelo, algo a lo que volveremos en la siguiente sección. Y max_rounds junto con max_tool_result_chars son los límites que impiden que un agente se descontrole: el primero limita cuántas rondas de herramientas puede consumir una pregunta, el segundo limita cuánto de una página raspada vuelve a entrar en el contexto. La función interesante de este módulo es el constructor de URL:
El servidor MCP alojado de Apify acepta un parámetro de consulta tools que decide qué herramientas anuncia. Sin un APIFY_TOKEN pedimos las cuatro herramientas anónimas que funcionan sin autenticación: búsqueda de Actors, detalles de Actors, búsqueda de documentación y descarga de documentación. Eso significa que alguien puede clonar el proyecto, añadir solo una clave de Venice y aun así tener un agente funcional capaz de investigar Actors de Apify. Simplemente no puede ejecutar ninguno.

Hablar con Venice

Venice es compatible con OpenAI, así que podemos usar el SDK de OpenAI para chat completions y httpx a secas para la llamada de descubrimiento de modelos. Crea src/venice_terminal_agent/venice.py:
Dos clientes para una API parece redundante, pero hacen trabajos distintos. AsyncOpenAI nos da gratis el helper de streaming y los tool_calls tipados. El cliente httpx en crudo está ahí para los endpoints de Venice que el SDK de OpenAI no conoce, que en este proyecto significa /models/traits. El timeout del chat es mucho más largo que el timeout de descubrimiento a propósito. Una pregunta que desencadena un rastreo web puede tardar legítimamente un par de minutos.

Descubrir un modelo en tiempo de ejecución

Los IDs de modelo de Venice rotan, y hardcodear uno es la forma más rápida de publicar un agente que se rompe en un mes. GET /models/traits mapea nombres de traits estables al modelo que actualmente cumple ese rol, así que pedimos function_calling_default en lugar de nombrar un modelo:
La precedencia aquí importa: una bandera --model explícita gana, luego VENICE_MODEL del entorno, y después la consulta del trait. Así que la ruta por defecto no necesita configuración alguna, pero aún puedes fijar un modelo cuando estés comparando el comportamiento entre dos de ellos.
No todos los modelos de texto admiten function calling. Pedir el trait function_calling_default significa que obtienes uno que sí lo hace, sin mantener una lista tú mismo. Consulta Deprecaciones para saber con qué frecuencia cambian los IDs subyacentes.

Completions en streaming

Ahora añade la llamada de completion:
Hacemos streaming para que el usuario vea el texto aparecer mientras se genera, pero aun así queremos el mensaje ensamblado después: las llamadas a herramientas llegan en fragmentos repartidos entre muchos chunks, y volver a montarlas a mano es tedioso. El context manager stream() del SDK se encarga de ambas cosas: los eventos content.delta alimentan la salida de la terminal, y get_final_completion() devuelve un mensaje completo con los tool_calls ya cosidos. La petición en sí la construye una función aparte para que siga siendo fácil de probar:
extra_body es la forma en que el SDK de OpenAI pasa campos que no modela, y ahí es donde va venice_parameters. Poner include_venice_system_prompt a false mantiene el prompt de asistente por defecto de Venice fuera de la conversación, de modo que nuestro propio system prompt es la única instrucción que recibe el modelo. Para un agente con reglas estrictas sobre herramientas, eso es lo que quieres. Adjunta tools y tool_choice solo cuando haya al menos una herramienta. Enviar un array tools vacío es una forma innecesaria de confundir a un modelo. El módulo también tiene un helper format_http_error() que convierte un APIStatusError o un httpx2.HTTPStatusError en una cadena de una línea con el código de estado y el cuerpo de la respuesta. Los agentes fallan en la frontera de la API más que en cualquier otro sitio, y un mensaje legible ahí ahorra mucho adivinar.

Convertir herramientas MCP en herramientas de Venice

Las herramientas MCP y las herramientas de función al estilo OpenAI describen lo mismo con formas distintas. Ambas tienen un nombre, una descripción y un JSON Schema para los argumentos. La traducción es mayormente mecánica, con una pega: los nombres de las herramientas de Apify incluyen caracteres que los nombres de función no permiten. Una herramienta de Actor puede llamarse apify/rag-web-browser, y esa barra no es válida. Así que saneamos los nombres a la salida y guardamos un mapa para poder restaurarlos a la vuelta. En src/venice_terminal_agent/tools.py, un ToolCatalog hace la traducción y guarda el mapa:
Tres pequeños helpers hacen el trabajo poco glamuroso. sanitize_tool_name() sustituye los caracteres ilegales por guiones, añade un prefijo a los nombres que empiezan por dígito y trunca a 64 caracteres. unique_name() añade después un sufijo numérico si ese truncamiento hizo colisionar dos Actors — lo que te ahorra un bug genuinamente confuso en el que el modelo llama a un Actor y se ejecuta otro distinto. tool_input_schema() se las arregla con servidores MCP que devuelven un dict, un modelo de Pydantic o nada en absoluto.

Formatear los resultados de vuelta al contexto

Los resultados de las herramientas van directos a la conversación, así que tienen que ser una cadena, y necesitan un límite de tamaño. Raspar un sitio de documentación puede devolver fácilmente más texto del que cabe en la ventana de contexto. format_tool_result() prefiere structured_content cuando el servidor lo proporciona, y en caso contrario aplana los bloques de contenido a texto, lidiando con bloques que no son TextContent. Termina con las dos líneas que importan:
El aviso de truncamiento está escrito para el modelo, no para ti. Decirle que el contenido fue recortado y sugerirle filtros, límites u offsets suele bastar para que haga una segunda llamada más acotada en lugar de asumir que lo vio todo. Los errores se envuelven como {"error": "..."} en lugar de lanzarse. Una llamada a herramienta fallida es información sobre la que el modelo puede actuar — puede elegir otro Actor o corregir sus argumentos — y solo puede hacerlo si el fallo le llega como un resultado de herramienta normal.

Marcar las herramientas que cuestan dinero

Las herramientas de Apify se dividen limpiamente en dos grupos: las que leen metadatos y documentación, y las que arrancan cómputo. Queremos confirmación para el segundo grupo, así que ponemos el primero en una lista de permitidos:
Una lista de permitidos en lugar de una lista de bloqueados es la decisión importante. Apify sigue añadiendo herramientas y Actors, y cualquier cosa que el agente no haya visto antes pregunta primero por defecto. Hazlo al revés y cada Actor nuevo queda aprobado automáticamente.

Conectarse a Apify por MCP

Apify ofrece dos vías de entrada. El servidor alojado en https://mcp.apify.com habla Streamable HTTP, y @apify/actors-mcp-server corre localmente sobre stdio vía npx. Admitiremos ambas, porque encajan en situaciones distintas: la alojada no necesita Node.js, y stdio mantiene la conexión en tu propia máquina. En src/venice_terminal_agent/apify_mcp.py, una clase ApifyMcp envuelve la sesión conectada. Su call_tool() es donde el nombre saneado se traduce de vuelta — Venice envía apify-rag-web-browser, Apify recibe apify/rag-web-browser:
Construir el catálogo requiere un bucle de cursor sobre client.list_tools(), ya que un token con acceso a muchos Actors produce una lista paginada.

Ser dueño del transporte

Una conexión MCP es un recurso async de larga vida, y también lo es el cliente HTTP que hay debajo. Un async context manager ApifyMcpSession guarda ambos en un AsyncExitStack, elige un transporte según la configuración y carga el catálogo. El detalle que vale la pena copiar es la limpieza:
Ese except BaseException importa más de lo que parece. Si listar las herramientas falla después de que el transporte esté levantado, sin él filtras un subproceso o un socket abierto cada vez que el agente falla al arrancar. Aquí están los dos transportes:
Fíjate en el timeout de lectura de 300 segundos en el transporte HTTP. Las ejecuciones de Actors son lentas, y el timeout por defecto de 30 segundos cortará rastreos perfectamente sanos. Fíjate también en que el subproceso stdio recibe únicamente APIFY_TOKEN en su entorno, no todo tu entorno de shell — incluida tu clave de Venice.

Ejecutar una llamada a herramienta

La última pieza de este módulo, execute_venice_tool_call(), convierte una llamada a herramienta de Venice en un resultado de cadena. Envuelve las dos clases de fallo — argumentos imposibles de parsear y una llamada fallida a Apify — como {"error": "..."} en lugar de lanzar una excepción:
Los argumentos JSON malformados ocurren. Cuando ocurren, entregarle al modelo {"error": "invalid arguments: ..."} te consigue una llamada corregida en la siguiente ronda, mientras que lanzar una excepción mata la sesión y pierde la conversación.

Ejecutar el bucle de herramientas

Ahora el agente en sí, en src/venice_terminal_agent/agent.py. Empieza con el system prompt:
Cada regla de ahí corresponde a un fallo concreto que queremos evitar. “Prefer search-actors and fetch-actor-details before calling an unfamiliar Actor” existe porque un modelo que adivina el esquema de entrada de un Actor desperdicia una ejecución de pago. La línea sobre herramientas rechazadas existe porque, de lo contrario, el modelo trata un rechazo como un error transitorio y lo intenta de nuevo inmediatamente. La clase Agent recibe los dos clientes, un modelo, un límite de rondas y tres callbacks:
Esos callbacks son lo que mantiene al agente independiente de la terminal. on_tool informa de una llamada a herramienta, on_text recibe los tokens en streaming, y approve_tool responde la pregunta de confirmación. Cámbialos y el mismo agente funciona detrás de una aplicación web o un bot de chat. Aquí está el bucle:
Ese es el agente entero: llama al modelo, y si pidió herramientas, ejecútalas y llama de nuevo. El índice start y el del en el manejador de excepciones merecen una mirada más de cerca. Si una pregunta falla a medio camino — error de red, Ctrl+C, límite de rondas — la conversación queda con un turno del asistente pidiendo herramientas que nunca produjeron resultados. Venice rechazará la siguiente petición, porque un turno con tool_calls debe ir seguido de los mensajes tool correspondientes. Retroceder hasta donde empezó la pregunta significa que una pregunta fallida no deja rastro y el REPL sigue siendo usable.

Devolver el turno del asistente

Esta función es pequeña y fácil de hacer mal:
La implementación obvia es message.model_dump(exclude_none=True), y rompe el tool calling. Un turno de llamada a herramienta tiene content: null, y eliminar esa clave cambia la forma del mensaje que devuelves. exclude_unset=True es la versión que quieres: conserva los valores null que el modelo realmente estableció, y omite los campos que nunca envió. También preserva campos que el esquema de OpenAI no conoce. Los modelos de razonamiento devuelven reasoning_content y reasoning_details, y esos necesitan sobrevivir el viaje de ida y vuelta para que el modelo mantenga su propia cadena de pensamiento a través de las rondas de herramientas.

Ejecutar y controlar las llamadas

Los modelos pueden pedir varias herramientas en un turno, y no hay razón para ejecutarlas de una en una. Pero sí queremos pedir aprobación de forma secuencial, ya que unos avisos de confirmación entrelazados serían ilegibles. Así que primero planificamos y luego ejecutamos en concurrencia:
Las herramientas rechazadas también reciben un mensaje tool. Cada tool_call_id necesita una respuesta, y saltarse una deja la conversación malformada. La respuesta simplemente explica que el usuario dijo que no. La comprobación de aprobación en sí consulta ambos nombres, ya que el modelo trabaja con nombres saneados y nuestra lista de permitidos usa nombres MCP:

Añadir el CLI

El CLI de src/venice_terminal_agent/cli.py es Typer más un REPL, y es el archivo menos interesante del proyecto — pero tres detalles suyos merecen copiarse. El primero es que las opciones de Typer están tipadas como opcionales y su valor por defecto es None, para que el cargador de configuración pueda distinguir “no se pasó” de “se pasó un valor falsy”:
Esos valores por defecto None son lo que hace seguro el traspaso a load_settings(), porque una bandera que no usaste nunca sobrescribe el entorno:
El yes or None es la misma idea aplicada a una bandera booleana: --yes la activa, y omitirla pasa None en lugar de False, de modo que AUTO_APPROVE_TOOLS del entorno sobrevive. El segundo es el orden de arranque. Resuelve el modelo, luego abre la sesión MCP, luego construye el agente — y cierra el cliente de Venice en un finally, ya que tanto la sesión MCP como los clientes HTTP necesitan desmontarse tanto si la pregunta tuvo éxito como si no:
El tercero es el aprobador, que es la única pieza del agente que existe puramente para proteger tu factura de Apify:
La comprobación isatty() es la parte que la gente olvida. Ejecuta el agente desde cron o CI y no hay nadie para responder al aviso, así que una implementación ingenua o se cuelga para siempre o aprueba en silencio. Aquí declina, dice por qué y deja que el modelo siga adelante con las herramientas de solo lectura. default=False significa que un Enter perdido no arranca una ejecución de pago, e interrumpir el aviso cuenta como un no. El resto del módulo es trabajo de terminal corriente, así que vale la pena saber qué hay ahí en lugar de leerlo: un bucle REPL con prompt_toolkit, una búsqueda _handle_command() para los comandos slash, un render.py con helpers de Rich, y un _settings_error() que convierte un VENICE_API_KEY ausente en un mensaje legible en lugar de un traceback de Pydantic. Tres de esas piezas llevan una decisión: Los comandos slash son /help, /clear, /quit, y dos que se ganan el sueldo: /tools imprime el catálogo cargado, lo que suele explicar por qué el agente eligió una herramienta rara, y /reload recoge los Actors que añadiste a tu cuenta de Apify a mitad de sesión. Por último, conecta el punto de entrada en pyproject.toml para que uv run venice-agent funcione:

Ejecutar el agente

Inicia una sesión interactiva:
O haz una sola pregunta y sal:
Verás el banner y luego las llamadas a herramientas según ocurren:
Lee la línea de Apify de ese banner antes que nada. Si dice “anonymous Apify tools only”, tu APIFY_TOKEN no se cargó, y es mucho mejor notarlo ahora que después de diez minutos preguntándote por qué el agente se niega a ejecutar un Actor. Restringe el catálogo de herramientas cuando sepas lo que necesitas:
Un catálogo más pequeño no es solo cuestión de coste. Los modelos suelen elegir mejor cuando hay menos herramientas, y más relevantes, entre las que escoger, y --tools es la forma más barata de acotar la elección. Ejecuta el servidor MCP localmente en lugar de usar el alojado:
Esta opción necesita Node.js en tu PATH, ya que lanza @apify/actors-mcp-server a través de npx, y necesita un APIFY_TOKEN — no hay modo anónimo para el servidor local. Y cuando realmente quieras ejecuciones de Actors sin supervisión:

Probar las piezas

Nada de la lógica interesante de aquí necesita red. Un FakeVenice que va sacando respuestas de una lista guionizada, más un FakeApify que construye un ToolCatalog real a partir de herramientas SimpleNamespace, es suficiente para hacer una ronda completa de herramientas:
Hacer aserciones sobre la secuencia de roles es un buen hábito para el código de agentes. Atrapa los bugs de conversación malformada que de otro modo son invisibles hasta que Venice devuelve un 400. Vale la pena escribir tres pruebas más, y todas hacen aserciones sobre agent.messages de la misma manera. Que una ejecución fallida retrocede el historial hasta solo ["system"], tanto si falló por un error de Venice como por agotar max_rounds. Que una herramienta de solo lectura se ejecuta igualmente cuando el aprobador devuelve False. Y que una herramienta de pago rechazada deja un mensaje tool que contiene declined mientras apify.calls permanece vacío. Ejecuta la suite con:

Notas de privacidad y coste

Un agente que llega a dos APIs merece precisión: La retención cero de datos de Venice cubre el lado del modelo. No cubre Apify, y una ejecución de un Actor escribe resultados en tu cuenta de Apify. Si eso importa para una tarea concreta, ejecuta sin APIFY_TOKEN y quédate con las herramientas anónimas de descubrimiento. Sobre el coste, tres hábitos dan mucho de sí:
  • Deja --yes desactivado durante el desarrollo. Ver qué Actors quiere ejecutar el modelo es informativo por sí mismo.
  • Usa --tools para acotar el catálogo a Actors que realmente hayas revisado.
  • Mantén max_rounds modesto. Doce rondas son de sobra para tareas de investigación, y un techo más bajo limita el daño cuando un modelo se queda atascado en un bucle.

Extender este ejemplo

El bucle es la base. Una vez que funciona, algunas direcciones útiles incluyen:
  • Añade un segundo servidor MCP. Nada en Agent es específico de Apify, así que fusionar catálogos de varios servidores consiste sobre todo en aplicar espacios de nombres a las herramientas.
  • Persiste las conversaciones en SQLite para poder retomar una sesión o auditar lo que devolvió un Actor.
  • Añade presupuestos por herramienta que rastreen las ejecuciones de Actors y se detengan en un techo, en lugar de confirmar cada una.
  • Cachea los resultados de herramientas por nombre y argumentos, para que las consultas repetidas de documentación no vuelvan a rastrear.
  • Fija un modelo con --model y compara la calidad de selección de herramientas frente a function_calling_default.
  • Sustituye el aprobador por una función de política que auto-apruebe Actors concretos con argumentos concretos y pregunte por todo lo demás.
Para un punto de partida más pequeño sin MCP, Construir un agente que usa herramientas cubre el mismo bucle con tres funciones locales de Python.

Para terminar

¡Gracias por leer! Ojalá esto te haya ayudado a construir un agente de terminal que piensa con Venice y actúa a través de Apify. El patrón que vale la pena llevarse es lo poco que este código trata sobre inteligencia. El modelo devuelve llamadas a herramientas, y tu código decide cuáles pueden ejecutarse, cómo vuelven sus resultados y qué pasa cuando algo falla. Una vez que esas decisiones son explícitas, añadir capacidades es sobre todo cuestión de apuntar el agente a más herramientas.