# Hosting AI agents

Copy for LLM

In this guide, you'll learn how to host an AI agent as an Apify Actor, using the agent framework of your choice.

## Introduction[](#introduction)

An AI agent is a program that uses a large language model (LLM) to decide what to do next. Instead of following a fixed script, it reasons about a task, calls tools to gather information or take actions, and loops until the task is done. The Python ecosystem offers several frameworks for building such agents, and the Apify SDK doesn't lock you into any of them.

Apify Actors are well suited to hosting agents:

* An agent is just a Python program, so wrap it in `async with Actor:` to run it on the [Apify platform](https://docs.apify.com/platform).
* The platform scales runs, schedules them, and keeps logs, so you don't operate any infrastructure yourself.
* [Apify Store](https://apify.com/store) gives your agent thousands of ready-made tools. Any Actor can become a tool the agent calls: a scraper, a browser automation, an API wrapper.
* The [Apify OpenRouter proxy](https://apify.com/apify/openrouter) lets the agent talk to an LLM without managing a provider API key.
* The [pay-per-event charging](https://docs.apify.com/sdk/python/sdk/python/docs/concepts/pay-per-event.md) lets you monetize the agent and pass LLM costs on to its users.

Each example in this guide shows a complete, single-file Actor for one framework. They all read the input, run the agent, and store the result in the default dataset. Each one defines its input as a [Pydantic model](https://docs.apify.com/sdk/python/sdk/python/docs/guides/input-validation.md) with default values, so the fields stay typed and validated.

## Choosing a framework[](#choosing-a-framework)

Apify maintains a ready-made Actor template for each of the popular agent frameworks. Each template is a complete, deployable Actor that you can use as a starting point.

| Framework                 | Good for                                                | Template                                                                         |
| ------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------- |
| [LangGraph](#langgraph)   | Graph-based agents with explicit state and control flow | [`python-langgraph`](https://apify.com/templates/python-langgraph)               |
| [CrewAI](#crewai)         | Multi-agent "crews" that collaborate on a task          | [`python-crewai`](https://apify.com/templates/python-crewai)                     |
| [PydanticAI](#pydanticai) | Typed, Pydantic-native agents with tool calling         | [`python-pydanticai`](https://apify.com/templates/python-pydanticai)             |
| [LlamaIndex](#llamaindex) | Retrieval-augmented agents over your own data           | [`python-llamaindex-agent`](https://apify.com/templates/python-llamaindex-agent) |
| [Smolagents](#smolagents) | Lightweight code-writing agents                         | [`python-smolagents`](https://apify.com/templates/python-smolagents)             |

All of these templates live in the [actor-templates repository](https://github.com/apify/actor-templates). To create a project, use the [Apify CLI](https://docs.apify.com/cli), for example:

```
apify create my-agent --template python-pydanticai
```

## Connecting to an LLM[](#connecting-to-an-llm)

There are two ways to give an agent an LLM.

### Apify OpenRouter proxy[](#apify-openrouter-proxy)

The [Apify OpenRouter proxy](https://apify.com/apify/openrouter) is an OpenAI-compatible endpoint at `https://openrouter.apify.actor/api/v1` that fronts the full [OpenRouter](https://openrouter.ai) model catalog. Any framework with an OpenAI-compatible client can point its base URL at the proxy, which is what every example in this guide does. Note that:

* The token usage is billed against the Apify account running the Actor, so no provider API key is required.
* The Actor authenticates with the proxy using the `APIFY_TOKEN` that the platform injects into every run.
* To switch models, change the `model` input to any [OpenRouter model slug](https://openrouter.ai/models), for example `openai/gpt-5.4-mini`.

### Your own provider key[](#your-own-provider-key)

Call a provider such as OpenAI directly with your own API key. Keep the key out of the Actor input and source code. Read it from an environment variable, which on the platform you set as a [secret environment variable](https://docs.apify.com/platform/actors/development/programming-interface/environment-variables) and locally you export in your shell.

## LangGraph[](#langgraph)

[LangGraph](https://www.langchain.com/langgraph) models an agent as a graph, where nodes do the work and edges decide what runs next. The explicit state and control flow make complex, multi-step logic easy to follow, including loops that revisit earlier nodes. It builds on [LangChain](https://www.langchain.com/), so any LangChain chat model works. To install it, use:

```
pip install langchain-openai langchain-core langgraph pydantic
```

The install also includes [Pydantic](https://docs.pydantic.dev/) for the Actor's typed input and output models.

The following Actor builds a small graph with two nodes. A `fetch` node scrapes the page with the [RAG Web Browser](https://apify.com/apify/rag-web-browser) Actor, then a `summarize` node distills the clean Markdown into a structured summary. A conditional edge loops back into `summarize` until the summary is detailed enough:

[Run on](https://console.apify.com/actors/HH9rhkFXiZbheuq1V?runConfig=eyJ1IjoiRWdQdHczb2VqNlRhRHQ1cW4iLCJ2IjoxfQ.eyJpbnB1dCI6IntcImNvZGVcIjpcImltcG9ydCBhc3luY2lvXFxuaW1wb3J0IG9zXFxuZnJvbSBmdW5jdG9vbHMgaW1wb3J0IHBhcnRpYWxcXG5mcm9tIHR5cGluZyBpbXBvcnQgVHlwZWREaWN0XFxuXFxuZnJvbSBsYW5nY2hhaW5fY29yZS5ydW5uYWJsZXMgaW1wb3J0IFJ1bm5hYmxlXFxuZnJvbSBsYW5nY2hhaW5fb3BlbmFpIGltcG9ydCBDaGF0T3BlbkFJXFxuZnJvbSBsYW5nZ3JhcGguZ3JhcGggaW1wb3J0IEVORCwgU1RBUlQsIFN0YXRlR3JhcGhcXG5mcm9tIHB5ZGFudGljIGltcG9ydCBCYXNlTW9kZWxcXG5cXG5mcm9tIGFwaWZ5IGltcG9ydCBBY3Rvclxcblxcbk9QRU5ST1VURVJfQkFTRV9VUkwgPSAnaHR0cHM6Ly9vcGVucm91dGVyLmFwaWZ5LmFjdG9yL2FwaS92MSdcXG5NSU5fS0VZX1BPSU5UUyA9IDNcXG5NQVhfUkVWSVNJT05TID0gMlxcblxcblxcbmNsYXNzIEFjdG9ySW5wdXQoQmFzZU1vZGVsKTpcXG4gICAgXFxcIlxcXCJcXFwiVGhlIEFjdG9yIGlucHV0LCB2YWxpZGF0ZWQgd2l0aCBkZWZhdWx0IHZhbHVlcy5cXFwiXFxcIlxcXCJcXG5cXG4gICAgdXJsOiBzdHIgPSAnaHR0cHM6Ly9jcmF3bGVlLmRldidcXG4gICAgbW9kZWw6IHN0ciA9ICdvcGVuYWkvZ3B0LTUuNC1taW5pJ1xcblxcblxcbmNsYXNzIFBhZ2VTdW1tYXJ5KEJhc2VNb2RlbCk6XFxuICAgIFxcXCJcXFwiXFxcIlRoZSBzdHJ1Y3R1cmVkIHN1bW1hcnkgdGhlIGFnZW50IGV4dHJhY3RzIGZyb20gYSB3ZWIgcGFnZS5cXFwiXFxcIlxcXCJcXG5cXG4gICAgdGl0bGU6IHN0clxcbiAgICBzdW1tYXJ5OiBzdHJcXG4gICAga2V5X3BvaW50czogbGlzdFtzdHJdXFxuICAgIHRhcmdldF9hdWRpZW5jZTogc3RyXFxuXFxuXFxuY2xhc3MgU3RhdGUoVHlwZWREaWN0KTpcXG4gICAgXFxcIlxcXCJcXFwiVGhlIHN0YXRlIHRoYXQgZmxvd3MgYmV0d2VlbiB0aGUgZ3JhcGgncyBub2Rlcy5cXFwiXFxcIlxcXCJcXG5cXG4gICAgdXJsOiBzdHJcXG4gICAgcGFnZV90ZXh0OiBzdHJcXG4gICAgc3VtbWFyeTogUGFnZVN1bW1hcnlcXG4gICAgcmV2aXNpb25zOiBpbnRcXG5cXG5cXG5hc3luYyBkZWYgZmV0Y2goc3RhdGU6IFN0YXRlKSAtPiBkaWN0OlxcbiAgICBcXFwiXFxcIlxcXCJBIG5vZGUgdGhhdCBzY3JhcGVzIHRoZSBwYWdlIHRvIGNsZWFuIE1hcmtkb3duIHdpdGggdGhlIFJBRyBXZWIgQnJvd3NlciBBY3Rvci5cXFwiXFxcIlxcXCJcXG4gICAgcnVuX2lucHV0ID0geydxdWVyeSc6IHN0YXRlWyd1cmwnXSwgJ291dHB1dEZvcm1hdHMnOiBbJ21hcmtkb3duJ119XFxuICAgIHJ1biA9IGF3YWl0IEFjdG9yLmNhbGwoJ2FwaWZ5L3JhZy13ZWItYnJvd3NlcicsIHJ1bl9pbnB1dD1ydW5faW5wdXQpXFxuICAgIGRhdGFzZXQgPSBBY3Rvci5hcGlmeV9jbGllbnQuZGF0YXNldChydW4uZGVmYXVsdF9kYXRhc2V0X2lkKVxcbiAgICBpdGVtcyA9IChhd2FpdCBkYXRhc2V0Lmxpc3RfaXRlbXMoKSkuaXRlbXNcXG4gICAgaWYgbm90IGl0ZW1zIG9yIG5vdCBpdGVtc1swXS5nZXQoJ21hcmtkb3duJyk6XFxuICAgICAgICByYWlzZSBSdW50aW1lRXJyb3IoZidSQUcgV2ViIEJyb3dzZXIgcmV0dXJuZWQgbm8gY29udGVudCBmb3Ige3N0YXRlW1xcXCJ1cmxcXFwiXX0uJylcXG4gICAgcmV0dXJuIHsncGFnZV90ZXh0JzogaXRlbXNbMF1bJ21hcmtkb3duJ119XFxuXFxuXFxuYXN5bmMgZGVmIHN1bW1hcml6ZShzdGF0ZTogU3RhdGUsIHN0cnVjdHVyZWRfbGxtOiBSdW5uYWJsZSkgLT4gZGljdDpcXG4gICAgXFxcIlxcXCJcXFwiQSBub2RlIHRoYXQgc3VtbWFyaXplcyB0aGUgcGFnZSwgYXNraW5nIGZvciBtb3JlIGRlcHRoIG9uIGEgcmUtcnVuLlxcXCJcXFwiXFxcIlxcbiAgICBoaW50ID0gJydcXG4gICAgaWYgc3RhdGVbJ3JldmlzaW9ucyddOlxcbiAgICAgICAgaGludCA9IGYnIExpc3QgYXQgbGVhc3Qge01JTl9LRVlfUE9JTlRTfSBkaXN0aW5jdCBrZXkgcG9pbnRzLidcXG4gICAgcHJvbXB0ID0gZidTdW1tYXJpemUgdGhpcyBwYWdlLntoaW50fVxcXFxuXFxcXG57c3RhdGVbXFxcInBhZ2VfdGV4dFxcXCJdfSdcXG4gICAgc3VtbWFyeSA9IGF3YWl0IHN0cnVjdHVyZWRfbGxtLmFpbnZva2UocHJvbXB0KVxcbiAgICByZXR1cm4geydzdW1tYXJ5Jzogc3VtbWFyeSwgJ3JldmlzaW9ucyc6IHN0YXRlWydyZXZpc2lvbnMnXSArIDF9XFxuXFxuXFxuZGVmIHJvdXRlKHN0YXRlOiBTdGF0ZSkgLT4gc3RyOlxcbiAgICBcXFwiXFxcIlxcXCJBIGNvbmRpdGlvbmFsIGVkZ2UgdGhhdCBsb29wcyBiYWNrIGZvciBhbm90aGVyIHBhc3Mgd2hpbGUgdGhlIHN1bW1hcnkgaXMgdGhpbi5cXFwiXFxcIlxcXCJcXG4gICAgdGhpbiA9IGxlbihzdGF0ZVsnc3VtbWFyeSddLmtleV9wb2ludHMpIDwgTUlOX0tFWV9QT0lOVFNcXG4gICAgaWYgdGhpbiBhbmQgc3RhdGVbJ3JldmlzaW9ucyddIDwgTUFYX1JFVklTSU9OUzpcXG4gICAgICAgIHJldHVybiAnc3VtbWFyaXplJ1xcbiAgICByZXR1cm4gRU5EXFxuXFxuXFxuYXN5bmMgZGVmIG1haW4oKSAtPiBOb25lOlxcbiAgICBhc3luYyB3aXRoIEFjdG9yOlxcbiAgICAgICAgIyBQYXJzZSB0aGUgQWN0b3IgaW5wdXQgaW50byB0aGUgdHlwZWQgbW9kZWwsIGZpbGxpbmcgaW4gZGVmYXVsdHMuXFxuICAgICAgICBhY3Rvcl9pbnB1dCA9IEFjdG9ySW5wdXQubW9kZWxfdmFsaWRhdGUoYXdhaXQgQWN0b3IuZ2V0X2lucHV0KCkgb3Ige30pXFxuICAgICAgICB1cmwgPSBhY3Rvcl9pbnB1dC51cmxcXG4gICAgICAgIG1vZGVsID0gYWN0b3JfaW5wdXQubW9kZWxcXG5cXG4gICAgICAgICMgUm91dGUgdGhlIExMTSB0aHJvdWdoIHRoZSBBcGlmeSBPcGVuUm91dGVyIHByb3h5IChubyBwcm92aWRlciBrZXkgbmVlZGVkKS5cXG4gICAgICAgIGxsbSA9IENoYXRPcGVuQUkoXFxuICAgICAgICAgICAgbW9kZWw9bW9kZWwsXFxuICAgICAgICAgICAgYmFzZV91cmw9T1BFTlJPVVRFUl9CQVNFX1VSTCxcXG4gICAgICAgICAgICBhcGlfa2V5PW9zLmVudmlyb25bJ0FQSUZZX1RPS0VOJ10sXFxuICAgICAgICApXFxuXFxuICAgICAgICAjIGB3aXRoX3N0cnVjdHVyZWRfb3V0cHV0YCBtYWtlcyB0aGUgbm9kZSByZXR1cm4gYSB2YWxpZGF0ZWQgYFBhZ2VTdW1tYXJ5YC5cXG4gICAgICAgIHN0cnVjdHVyZWRfbGxtID0gbGxtLndpdGhfc3RydWN0dXJlZF9vdXRwdXQoUGFnZVN1bW1hcnkpXFxuXFxuICAgICAgICAjIFdpcmUgdGhlIG5vZGVzIGludG8gYSBncmFwaC4gSXRzIGNvbmRpdGlvbmFsIGVkZ2UgbG9vcHMgYmFjayBpbnRvIGBzdW1tYXJpemVgXFxuICAgICAgICAjIHVudGlsIHRoZSBzdW1tYXJ5IGlzIGRldGFpbGVkIGVub3VnaC4gYHBhcnRpYWxgIGJpbmRzIGBzdHJ1Y3R1cmVkX2xsbWAgdG8gaXQuXFxuICAgICAgICBncmFwaCA9IFN0YXRlR3JhcGgoU3RhdGUpXFxuICAgICAgICBncmFwaC5hZGRfbm9kZSgnZmV0Y2gnLCBmZXRjaClcXG4gICAgICAgIGdyYXBoLmFkZF9ub2RlKCdzdW1tYXJpemUnLCBwYXJ0aWFsKHN1bW1hcml6ZSwgc3RydWN0dXJlZF9sbG09c3RydWN0dXJlZF9sbG0pKVxcbiAgICAgICAgZ3JhcGguYWRkX2VkZ2UoU1RBUlQsICdmZXRjaCcpXFxuICAgICAgICBncmFwaC5hZGRfZWRnZSgnZmV0Y2gnLCAnc3VtbWFyaXplJylcXG4gICAgICAgIGdyYXBoLmFkZF9jb25kaXRpb25hbF9lZGdlcygnc3VtbWFyaXplJywgcm91dGUpXFxuICAgICAgICBhZ2VudCA9IGdyYXBoLmNvbXBpbGUoKVxcblxcbiAgICAgICAgcmVzdWx0ID0gYXdhaXQgYWdlbnQuYWludm9rZSh7J3VybCc6IHVybCwgJ3JldmlzaW9ucyc6IDB9KVxcbiAgICAgICAgc3VtbWFyeSA9IHJlc3VsdFsnc3VtbWFyeSddXFxuICAgICAgICBBY3Rvci5sb2cuaW5mbyhmJ1BhZ2Ugc3VtbWFyeTpcXFxcbntzdW1tYXJ5Lm1vZGVsX2R1bXBfanNvbihpbmRlbnQ9Mil9JylcXG4gICAgICAgIGF3YWl0IEFjdG9yLnB1c2hfZGF0YSh7J3VybCc6IHVybCwgKipzdW1tYXJ5Lm1vZGVsX2R1bXAoKX0pXFxuXFxuXFxuaWYgX19uYW1lX18gPT0gJ19fbWFpbl9fJzpcXG4gICAgYXN5bmNpby5ydW4obWFpbigpKVxcblwifSIsIm9wdGlvbnMiOnsiYnVpbGQiOiJsYXRlc3QiLCJjb250ZW50VHlwZSI6ImFwcGxpY2F0aW9uL2pzb247IGNoYXJzZXQ9dXRmLTgiLCJtZW1vcnkiOjEwMjQsInRpbWVvdXQiOjE4MH19.4yJ9FMp_WElksOP_MRl8E9CCQxLOxDfiprGLLmBDHLI\&asrc=run_on_apify)

```
import asyncio

import os

from functools import partial

from typing import TypedDict



from langchain_core.runnables import Runnable

from langchain_openai import ChatOpenAI

from langgraph.graph import END, START, StateGraph

from pydantic import BaseModel



from apify import Actor



OPENROUTER_BASE_URL = 'https://openrouter.apify.actor/api/v1'

MIN_KEY_POINTS = 3

MAX_REVISIONS = 2





class ActorInput(BaseModel):

    """The Actor input, validated with default values."""



    url: str = 'https://crawlee.dev'

    model: str = 'openai/gpt-5.4-mini'





class PageSummary(BaseModel):

    """The structured summary the agent extracts from a web page."""



    title: str

    summary: str

    key_points: list[str]

    target_audience: str





class State(TypedDict):

    """The state that flows between the graph's nodes."""



    url: str

    page_text: str

    summary: PageSummary

    revisions: int





async def fetch(state: State) -> dict:

    """A node that scrapes the page to clean Markdown with the RAG Web Browser Actor."""

    run_input = {'query': state['url'], 'outputFormats': ['markdown']}

    run = await Actor.call('apify/rag-web-browser', run_input=run_input)

    dataset = Actor.apify_client.dataset(run.default_dataset_id)

    items = (await dataset.list_items()).items

    if not items or not items[0].get('markdown'):

        raise RuntimeError(f'RAG Web Browser returned no content for {state["url"]}.')

    return {'page_text': items[0]['markdown']}





async def summarize(state: State, structured_llm: Runnable) -> dict:

    """A node that summarizes the page, asking for more depth on a re-run."""

    hint = ''

    if state['revisions']:

        hint = f' List at least {MIN_KEY_POINTS} distinct key points.'

    prompt = f'Summarize this page.{hint}\n\n{state["page_text"]}'

    summary = await structured_llm.ainvoke(prompt)

    return {'summary': summary, 'revisions': state['revisions'] + 1}





def route(state: State) -> str:

    """A conditional edge that loops back for another pass while the summary is thin."""

    thin = len(state['summary'].key_points) < MIN_KEY_POINTS

    if thin and state['revisions'] < MAX_REVISIONS:

        return 'summarize'

    return END





async def main() -> None:

    async with Actor:

        # Parse the Actor input into the typed model, filling in defaults.

        actor_input = ActorInput.model_validate(await Actor.get_input() or {})

        url = actor_input.url

        model = actor_input.model



        # Route the LLM through the Apify OpenRouter proxy (no provider key needed).

        llm = ChatOpenAI(

            model=model,

            base_url=OPENROUTER_BASE_URL,

            api_key=os.environ['APIFY_TOKEN'],

        )



        # `with_structured_output` makes the node return a validated `PageSummary`.

        structured_llm = llm.with_structured_output(PageSummary)



        # Wire the nodes into a graph. Its conditional edge loops back into `summarize`

        # until the summary is detailed enough. `partial` binds `structured_llm` to it.

        graph = StateGraph(State)

        graph.add_node('fetch', fetch)

        graph.add_node('summarize', partial(summarize, structured_llm=structured_llm))

        graph.add_edge(START, 'fetch')

        graph.add_edge('fetch', 'summarize')

        graph.add_conditional_edges('summarize', route)

        agent = graph.compile()



        result = await agent.ainvoke({'url': url, 'revisions': 0})

        summary = result['summary']

        Actor.log.info(f'Page summary:\n{summary.model_dump_json(indent=2)}')

        await Actor.push_data({'url': url, **summary.model_dump()})





if __name__ == '__main__':

    asyncio.run(main())
```

Note that:

* `ChatOpenAI` points its `base_url` at the Apify OpenRouter proxy, so any LangChain chat model works without a provider key. `with_structured_output(PageSummary)` makes the `summarize` node return a validated `PageSummary`.
* The `State` is a typed dict that flows between nodes. Each node returns a partial update, and LangGraph merges it into the shared state.
* `StateGraph` wires the nodes together: `fetch` runs first, then `summarize`. The `route` function is a conditional edge that either loops back into `summarize` for another pass or ends the run. The cycle and the `revisions` guard in the state are what a plain chain can't express, and they're the reason to reach for LangGraph.
* `fetch` scrapes the page by calling the [RAG Web Browser](https://apify.com/apify/rag-web-browser) Actor with `Actor.call`, then reads the result from its dataset. The Actor returns clean Markdown, so the agent reads text instead of raw HTML and uses fewer tokens. Any Apify scraping Actor works here. For details, see [Using Apify Actors as tools](#using-apify-actors-as-tools).

## CrewAI[](#crewai)

[CrewAI](https://www.crewai.com/) models a problem as a "crew" of role-playing agents, each with its own role, goal, and backstory. The agents work through tasks in sequence, handing results from one agent to the next. It fits well when a job splits into specialized roles that collaborate. To install it, use:

```
pip install crewai pydantic
```

The install also includes [Pydantic](https://docs.pydantic.dev/) for the Actor's typed input and output models.

The following Actor runs a two-agent crew. A researcher scrapes a [Crawlee](https://crawlee.dev/python) docs page with the [RAG Web Browser](https://apify.com/apify/rag-web-browser) Actor, exposed to the crew through a small custom tool. A writer then distills the notes into a structured guide of Crawlee's crawlers, each with its name, what it's built on, and what it's best for:

[Run on](https://console.apify.com/actors/HH9rhkFXiZbheuq1V?runConfig=eyJ1IjoiRWdQdHczb2VqNlRhRHQ1cW4iLCJ2IjoxfQ.eyJpbnB1dCI6IntcImNvZGVcIjpcImltcG9ydCBhc3luY2lvXFxuaW1wb3J0IG9zXFxuXFxuZnJvbSBjcmV3YWkgaW1wb3J0IExMTSwgQWdlbnQsIENyZXcsIFRhc2tcXG5mcm9tIGNyZXdhaS50b29scyBpbXBvcnQgQmFzZVRvb2xcXG5mcm9tIHB5ZGFudGljIGltcG9ydCBCYXNlTW9kZWwsIEZpZWxkXFxuXFxuZnJvbSBhcGlmeSBpbXBvcnQgQWN0b3JcXG5cXG5PUEVOUk9VVEVSX0JBU0VfVVJMID0gJ2h0dHBzOi8vb3BlbnJvdXRlci5hcGlmeS5hY3Rvci9hcGkvdjEnXFxuXFxuIyBPbiBhIGZyZXNoIGNvbnRhaW5lciwgQ3Jld0FJIHNob3dzIGEgb25lLXRpbWUgdHJhY2UtY29uc2VudCBwcm9tcHQgdGhhdCBibG9ja3Mgb25cXG4jIHN0ZGluLiBgQ1JFV0FJX1RFU1RJTkc9dHJ1ZWAgc3VwcHJlc3NlcyBpdC5cXG5vcy5lbnZpcm9uLnNldGRlZmF1bHQoJ0NSRVdBSV9URVNUSU5HJywgJ3RydWUnKVxcblxcblxcbmNsYXNzIEFjdG9ySW5wdXQoQmFzZU1vZGVsKTpcXG4gICAgXFxcIlxcXCJcXFwiVGhlIEFjdG9yIGlucHV0LCB2YWxpZGF0ZWQgd2l0aCBkZWZhdWx0IHZhbHVlcy5cXFwiXFxcIlxcXCJcXG5cXG4gICAgdXJsOiBzdHIgPSAnaHR0cHM6Ly9jcmF3bGVlLmRldi9weXRob24vZG9jcy9ndWlkZXMvYXJjaGl0ZWN0dXJlLW92ZXJ2aWV3J1xcbiAgICBtb2RlbDogc3RyID0gJ29wZW5haS9ncHQtNS40LW1pbmknXFxuXFxuXFxuY2xhc3MgQ3Jhd2xlcihCYXNlTW9kZWwpOlxcbiAgICBcXFwiXFxcIlxcXCJPbmUgY3Jhd2xlciBjbGFzcyB0aGF0IENyYXdsZWUgcHJvdmlkZXMuXFxcIlxcXCJcXFwiXFxuXFxuICAgIG5hbWU6IHN0clxcbiAgICBidWlsdF9vbjogc3RyXFxuICAgIGJlc3RfZm9yOiBzdHJcXG5cXG5cXG5jbGFzcyBDcmF3bGVyR3VpZGUoQmFzZU1vZGVsKTpcXG4gICAgXFxcIlxcXCJcXFwiVGhlIHN0cnVjdHVyZWQgZ3VpZGUgdGhlIGNyZXcgZGlzdGlsbHMgZnJvbSB0aGUgZG9jcyBwYWdlLlxcXCJcXFwiXFxcIlxcblxcbiAgICBjcmF3bGVyczogbGlzdFtDcmF3bGVyXVxcblxcblxcbmNsYXNzIFdlYkJyb3dzZXJUb29sSW5wdXQoQmFzZU1vZGVsKTpcXG4gICAgXFxcIlxcXCJcXFwiVGhlIHNpbmdsZSBhcmd1bWVudCB0aGUgYWdlbnQgcGFzc2VzIHRvIHRoZSB3ZWIgYnJvd3NlciB0b29sLlxcXCJcXFwiXFxcIlxcblxcbiAgICB1cmw6IHN0ciA9IEZpZWxkKGRlc2NyaXB0aW9uPSdUaGUgVVJMIG9mIHRoZSB3ZWIgcGFnZSB0byBmZXRjaC4nKVxcblxcblxcbmNsYXNzIFdlYkJyb3dzZXJUb29sKEJhc2VUb29sKTpcXG4gICAgXFxcIlxcXCJcXFwiQSBtaW5pbWFsIENyZXdBSSB0b29sIHRoYXQgcnVucyBhbiBBcGlmeSBBY3RvciB0byBmZXRjaCBhIHBhZ2UgYXMgTWFya2Rvd24uXFxcIlxcXCJcXFwiXFxuXFxuICAgIG5hbWU6IHN0ciA9ICd3ZWJfYnJvd3NlcidcXG4gICAgZGVzY3JpcHRpb246IHN0ciA9ICdGZXRjaCBhIHdlYiBwYWdlIGFuZCByZXR1cm4gaXRzIGNvbnRlbnQgYXMgY2xlYW4gTWFya2Rvd24uJ1xcbiAgICBhcmdzX3NjaGVtYTogdHlwZVtCYXNlTW9kZWxdID0gV2ViQnJvd3NlclRvb2xJbnB1dFxcblxcbiAgICBhc3luYyBkZWYgX3J1bihzZWxmLCB1cmw6IHN0cikgLT4gc3RyOlxcbiAgICAgICAgcnVuID0gYXdhaXQgQWN0b3IuY2FsbChcXG4gICAgICAgICAgICBhY3Rvcl9pZD0nYXBpZnkvcmFnLXdlYi1icm93c2VyJyxcXG4gICAgICAgICAgICBydW5faW5wdXQ9eydxdWVyeSc6IHVybCwgJ21heFJlc3VsdHMnOiAxfSxcXG4gICAgICAgIClcXG4gICAgICAgIGRhdGFzZXQgPSBBY3Rvci5hcGlmeV9jbGllbnQuZGF0YXNldChydW4uZGVmYXVsdF9kYXRhc2V0X2lkKVxcbiAgICAgICAgaXRlbXMgPSAoYXdhaXQgZGF0YXNldC5saXN0X2l0ZW1zKCkpLml0ZW1zXFxuICAgICAgICByZXR1cm4gJ1xcXFxuXFxcXG4nLmpvaW4oaXRlbS5nZXQoJ21hcmtkb3duJykgb3IgJycgZm9yIGl0ZW0gaW4gaXRlbXMpXFxuXFxuXFxuYXN5bmMgZGVmIG1haW4oKSAtPiBOb25lOlxcbiAgICBhc3luYyB3aXRoIEFjdG9yOlxcbiAgICAgICAgIyBQYXJzZSB0aGUgQWN0b3IgaW5wdXQgaW50byB0aGUgdHlwZWQgbW9kZWwsIGZpbGxpbmcgaW4gZGVmYXVsdHMuXFxuICAgICAgICBhY3Rvcl9pbnB1dCA9IEFjdG9ySW5wdXQubW9kZWxfdmFsaWRhdGUoYXdhaXQgQWN0b3IuZ2V0X2lucHV0KCkgb3Ige30pXFxuICAgICAgICB1cmwgPSBhY3Rvcl9pbnB1dC51cmxcXG4gICAgICAgIG1vZGVsID0gYWN0b3JfaW5wdXQubW9kZWxcXG5cXG4gICAgICAgICMgUm91dGUgdGhlIExMTSB0aHJvdWdoIHRoZSBBcGlmeSBPcGVuUm91dGVyIHByb3h5IChubyBwcm92aWRlciBrZXkgbmVlZGVkKS5cXG4gICAgICAgIGxsbSA9IExMTShcXG4gICAgICAgICAgICBtb2RlbD1tb2RlbCxcXG4gICAgICAgICAgICBiYXNlX3VybD1PUEVOUk9VVEVSX0JBU0VfVVJMLFxcbiAgICAgICAgICAgIGFwaV9rZXk9b3MuZW52aXJvblsnQVBJRllfVE9LRU4nXSxcXG4gICAgICAgICAgICBwcm92aWRlcj0nb3BlbmFpJyxcXG4gICAgICAgIClcXG5cXG4gICAgICAgICMgYFdlYkJyb3dzZXJUb29sYCBpcyBvdXIgb3duIEFjdG9yLWJhY2tlZCB0b29sLCB3cmFwcGluZyB0aGUgUkFHIFdlYiBCcm93c2VyXFxuICAgICAgICAjIHRvIGZldGNoIHRoZSBwYWdlIGFzIGNsZWFuIE1hcmtkb3duLlxcbiAgICAgICAgcmVzZWFyY2hlciA9IEFnZW50KFxcbiAgICAgICAgICAgIHJvbGU9J0RvY3VtZW50YXRpb24gcmVzZWFyY2hlcicsXFxuICAgICAgICAgICAgZ29hbD0oXFxuICAgICAgICAgICAgICAgICdGZXRjaCB0aGUgcGFnZSB3aXRoIHRoZSB3ZWJfYnJvd3NlciB0b29sIGFuZCBub3RlIGV2ZXJ5IGNyYXdsZXIgJ1xcbiAgICAgICAgICAgICAgICAnaXQgZGVzY3JpYmVzLidcXG4gICAgICAgICAgICApLFxcbiAgICAgICAgICAgIGJhY2tzdG9yeT0oXFxuICAgICAgICAgICAgICAgICdBIHJlc2VhcmNoZXIgd2hvIG5ldmVyIGFuc3dlcnMgZnJvbSBtZW1vcnkuIEJlZm9yZSB3cml0aW5nICdcXG4gICAgICAgICAgICAgICAgJ2FueXRoaW5nLCBhbHdheXMgcmVhZCB0aGUgYWN0dWFsIHBhZ2Ugd2l0aCB0aGUgd2ViX2Jyb3dzZXIgdG9vbCAnXFxuICAgICAgICAgICAgICAgICdhbmQgcmVwb3J0IG9ubHkgd2hhdCBpdCBzYXlzLidcXG4gICAgICAgICAgICApLFxcbiAgICAgICAgICAgIHRvb2xzPVtXZWJCcm93c2VyVG9vbCgpXSxcXG4gICAgICAgICAgICBsbG09bGxtLFxcbiAgICAgICAgKVxcblxcbiAgICAgICAgd3JpdGVyID0gQWdlbnQoXFxuICAgICAgICAgICAgcm9sZT0nVGVjaG5pY2FsIHdyaXRlcicsXFxuICAgICAgICAgICAgZ29hbD0nVHVybiByZXNlYXJjaCBub3RlcyBpbnRvIGEgY2xlYXIsIHN0cnVjdHVyZWQgY3Jhd2xlciBndWlkZS4nLFxcbiAgICAgICAgICAgIGJhY2tzdG9yeT0nQSB3cml0ZXIgd2hvIGRpc3RpbGxzIGRvY3MgaW50byBjb21wYXJpc29uIHRhYmxlcy4nLFxcbiAgICAgICAgICAgIGxsbT1sbG0sXFxuICAgICAgICApXFxuXFxuICAgICAgICByZXNlYXJjaCA9IFRhc2soXFxuICAgICAgICAgICAgZGVzY3JpcHRpb249KFxcbiAgICAgICAgICAgICAgICBmJ1VzZSB0aGUgd2ViX2Jyb3dzZXIgdG9vbCB0byBmZXRjaCB7dXJsfS4gQmFzZWQgb25seSBvbiB0aGUgcmV0dXJuZWQgJ1xcbiAgICAgICAgICAgICAgICAnTWFya2Rvd24sIGxpc3QgdGhlIGNyYXdsZXJzIHRoZSBwYWdlIGNvdmVycy4gRG8gbm90IHVzZSBwcmlvciBrbm93bGVkZ2UuJ1xcbiAgICAgICAgICAgICksXFxuICAgICAgICAgICAgZXhwZWN0ZWRfb3V0cHV0PSdOb3RlcyBvbiBlYWNoIGNyYXdsZXI6IG5hbWUsIHdoYXQgaXQgYnVpbGRzIG9uLCBpdHMgdXNlLicsXFxuICAgICAgICAgICAgYWdlbnQ9cmVzZWFyY2hlcixcXG4gICAgICAgIClcXG5cXG4gICAgICAgICMgYGNvbnRleHQ9W3Jlc2VhcmNoXWAgZmVlZHMgdGhlIHJlc2VhcmNoZXIncyBub3RlcyB0byB0aGUgd3JpdGVyLCBhbmRcXG4gICAgICAgICMgYG91dHB1dF9weWRhbnRpY2AgbWFrZXMgdGhlIGZpbmFsIHRhc2sgcmV0dXJuIGEgdmFsaWRhdGVkIGBDcmF3bGVyR3VpZGVgLlxcbiAgICAgICAgd3JpdGUgPSBUYXNrKFxcbiAgICAgICAgICAgIGRlc2NyaXB0aW9uPShcXG4gICAgICAgICAgICAgICAgJ0Zyb20gdGhlIG5vdGVzLCBjb21waWxlIGVhY2ggY3Jhd2xlciB3aXRoIHdoYXQgaXQgaXMgYnVpbHQgb24gJ1xcbiAgICAgICAgICAgICAgICAnYW5kIHdoYXQgaXQgaXMgYmVzdCBmb3IuJ1xcbiAgICAgICAgICAgICksXFxuICAgICAgICAgICAgZXhwZWN0ZWRfb3V0cHV0PSdBIGxpc3Qgb2YgY3Jhd2xlcnMgd2l0aCBuYW1lLCBidWlsdF9vbiwgYW5kIGJlc3RfZm9yLicsXFxuICAgICAgICAgICAgYWdlbnQ9d3JpdGVyLFxcbiAgICAgICAgICAgIGNvbnRleHQ9W3Jlc2VhcmNoXSxcXG4gICAgICAgICAgICBvdXRwdXRfcHlkYW50aWM9Q3Jhd2xlckd1aWRlLFxcbiAgICAgICAgKVxcblxcbiAgICAgICAgY3JldyA9IENyZXcoYWdlbnRzPVtyZXNlYXJjaGVyLCB3cml0ZXJdLCB0YXNrcz1bcmVzZWFyY2gsIHdyaXRlXSlcXG4gICAgICAgIGd1aWRlID0gKGF3YWl0IGNyZXcua2lja29mZl9hc3luYygpKS5weWRhbnRpY1xcblxcbiAgICAgICAgaWYgZ3VpZGUgaXMgTm9uZTpcXG4gICAgICAgICAgICByYWlzZSBSdW50aW1lRXJyb3IoJ1RoZSBjcmV3IGRpZCBub3QgcmV0dXJuIGEgc3RydWN0dXJlZCBDcmF3bGVyR3VpZGUuJylcXG5cXG4gICAgICAgIEFjdG9yLmxvZy5pbmZvKGYnQ3Jhd2xlciBndWlkZTpcXFxcbntndWlkZS5tb2RlbF9kdW1wX2pzb24oaW5kZW50PTIpfScpXFxuICAgICAgICBhd2FpdCBBY3Rvci5wdXNoX2RhdGEoW2NyYXdsZXIubW9kZWxfZHVtcCgpIGZvciBjcmF3bGVyIGluIGd1aWRlLmNyYXdsZXJzXSlcXG5cXG5cXG5pZiBfX25hbWVfXyA9PSAnX19tYWluX18nOlxcbiAgICBhc3luY2lvLnJ1bihtYWluKCkpXFxuXCJ9Iiwib3B0aW9ucyI6eyJidWlsZCI6ImxhdGVzdCIsImNvbnRlbnRUeXBlIjoiYXBwbGljYXRpb24vanNvbjsgY2hhcnNldD11dGYtOCIsIm1lbW9yeSI6MTAyNCwidGltZW91dCI6MTgwfX0.TfCJ2ENCxwJxin4JQ7Lx5C1-A4Yl-yu8C3hyZjCHte4\&asrc=run_on_apify)

```
import asyncio

import os



from crewai import LLM, Agent, Crew, Task

from crewai.tools import BaseTool

from pydantic import BaseModel, Field



from apify import Actor



OPENROUTER_BASE_URL = 'https://openrouter.apify.actor/api/v1'



# On a fresh container, CrewAI shows a one-time trace-consent prompt that blocks on

# stdin. `CREWAI_TESTING=true` suppresses it.

os.environ.setdefault('CREWAI_TESTING', 'true')





class ActorInput(BaseModel):

    """The Actor input, validated with default values."""



    url: str = 'https://crawlee.dev/python/docs/guides/architecture-overview'

    model: str = 'openai/gpt-5.4-mini'





class Crawler(BaseModel):

    """One crawler class that Crawlee provides."""



    name: str

    built_on: str

    best_for: str





class CrawlerGuide(BaseModel):

    """The structured guide the crew distills from the docs page."""



    crawlers: list[Crawler]





class WebBrowserToolInput(BaseModel):

    """The single argument the agent passes to the web browser tool."""



    url: str = Field(description='The URL of the web page to fetch.')





class WebBrowserTool(BaseTool):

    """A minimal CrewAI tool that runs an Apify Actor to fetch a page as Markdown."""



    name: str = 'web_browser'

    description: str = 'Fetch a web page and return its content as clean Markdown.'

    args_schema: type[BaseModel] = WebBrowserToolInput



    async def _run(self, url: str) -> str:

        run = await Actor.call(

            actor_id='apify/rag-web-browser',

            run_input={'query': url, 'maxResults': 1},

        )

        dataset = Actor.apify_client.dataset(run.default_dataset_id)

        items = (await dataset.list_items()).items

        return '\n\n'.join(item.get('markdown') or '' for item in items)





async def main() -> None:

    async with Actor:

        # Parse the Actor input into the typed model, filling in defaults.

        actor_input = ActorInput.model_validate(await Actor.get_input() or {})

        url = actor_input.url

        model = actor_input.model



        # Route the LLM through the Apify OpenRouter proxy (no provider key needed).

        llm = LLM(

            model=model,

            base_url=OPENROUTER_BASE_URL,

            api_key=os.environ['APIFY_TOKEN'],

            provider='openai',

        )



        # `WebBrowserTool` is our own Actor-backed tool, wrapping the RAG Web Browser

        # to fetch the page as clean Markdown.

        researcher = Agent(

            role='Documentation researcher',

            goal=(

                'Fetch the page with the web_browser tool and note every crawler '

                'it describes.'

            ),

            backstory=(

                'A researcher who never answers from memory. Before writing '

                'anything, always read the actual page with the web_browser tool '

                'and report only what it says.'

            ),

            tools=[WebBrowserTool()],

            llm=llm,

        )



        writer = Agent(

            role='Technical writer',

            goal='Turn research notes into a clear, structured crawler guide.',

            backstory='A writer who distills docs into comparison tables.',

            llm=llm,

        )



        research = Task(

            description=(

                f'Use the web_browser tool to fetch {url}. Based only on the returned '

                'Markdown, list the crawlers the page covers. Do not use prior knowledge.'

            ),

            expected_output='Notes on each crawler: name, what it builds on, its use.',

            agent=researcher,

        )



        # `context=[research]` feeds the researcher's notes to the writer, and

        # `output_pydantic` makes the final task return a validated `CrawlerGuide`.

        write = Task(

            description=(

                'From the notes, compile each crawler with what it is built on '

                'and what it is best for.'

            ),

            expected_output='A list of crawlers with name, built_on, and best_for.',

            agent=writer,

            context=[research],

            output_pydantic=CrawlerGuide,

        )



        crew = Crew(agents=[researcher, writer], tasks=[research, write])

        guide = (await crew.kickoff_async()).pydantic



        if guide is None:

            raise RuntimeError('The crew did not return a structured CrawlerGuide.')



        Actor.log.info(f'Crawler guide:\n{guide.model_dump_json(indent=2)}')

        await Actor.push_data([crawler.model_dump() for crawler in guide.crawlers])





if __name__ == '__main__':

    asyncio.run(main())
```

Note that:

* CrewAI's `LLM` points its `base_url` at the Apify OpenRouter proxy. The `openai/` model prefix selects its OpenAI-compatible client, so no provider key is needed.
* The `researcher` reads the page with `WebBrowserTool`, a custom tool that runs the [RAG Web Browser](https://apify.com/apify/rag-web-browser) Actor and returns the page as clean Markdown. The `writer` then turns its notes into the result. Listing both agents in one `Crew` runs them in order, and `context=[research]` hands the researcher's notes to the writer.
* `WebBrowserTool` subclasses CrewAI's `BaseTool`, so the crew needs no third-party tool wrapper. Its `_run` method is `async`, and CrewAI runs it natively, so the tool awaits `Actor.call` directly with no manual event-loop bridge.
* `output_pydantic=CrawlerGuide` makes the writer's task return a validated `CrawlerGuide`, read from the crew result's `.pydantic` and stored as one dataset row per crawler.
* On a fresh container, CrewAI shows a one-time trace-consent prompt. The template sets `CREWAI_TESTING=true` to suppress it. Do the same in your Dockerfile.

## PydanticAI[](#pydanticai)

[PydanticAI](https://ai.pydantic.dev/) is an agent framework from the team behind Pydantic. It's strongly typed and integrates with the [Pydantic models](https://docs.apify.com/sdk/python/sdk/python/docs/guides/input-validation.md) the Apify SDK already uses. Tool arguments and agent outputs are validated against your models, so the data stays structured end to end. To install it, use:

```
pip install pydantic-ai pydantic impit
```

The install also includes [Pydantic](https://docs.pydantic.dev/) for the Actor's typed input and output models, plus [Impit](https://github.com/apify/impit), the HTTP client the Actor uses to call external APIs.

The following Actor evaluates a Python package and recommends whether to use it. Because the LLM would otherwise guess from its training data, the agent calls the `fetch_pypi_metadata` tool, which uses [Impit](https://github.com/apify/impit) to fetch live metadata from the PyPI JSON API. It then returns a typed report with the package's name, its latest version, a short summary, and a recommendation grounded in those facts:

[Run on](https://console.apify.com/actors/HH9rhkFXiZbheuq1V?runConfig=eyJ1IjoiRWdQdHczb2VqNlRhRHQ1cW4iLCJ2IjoxfQ.eyJpbnB1dCI6IntcImNvZGVcIjpcImltcG9ydCBhc3luY2lvXFxuaW1wb3J0IG9zXFxuZnJvbSBodHRwIGltcG9ydCBIVFRQU3RhdHVzXFxuXFxuaW1wb3J0IGltcGl0XFxuZnJvbSBweWRhbnRpYyBpbXBvcnQgQmFzZU1vZGVsXFxuZnJvbSBweWRhbnRpY19haSBpbXBvcnQgQWdlbnRcXG5mcm9tIHB5ZGFudGljX2FpLm1vZGVscy5vcGVuYWkgaW1wb3J0IE9wZW5BSUNoYXRNb2RlbFxcbmZyb20gcHlkYW50aWNfYWkucHJvdmlkZXJzLm9wZW5haSBpbXBvcnQgT3BlbkFJUHJvdmlkZXJcXG5cXG5mcm9tIGFwaWZ5IGltcG9ydCBBY3Rvclxcblxcbk9QRU5ST1VURVJfQkFTRV9VUkwgPSAnaHR0cHM6Ly9vcGVucm91dGVyLmFwaWZ5LmFjdG9yL2FwaS92MSdcXG5QWVBJX0pTT05fVVJMID0gJ2h0dHBzOi8vcHlwaS5vcmcvcHlwaS97bmFtZX0vanNvbidcXG5cXG5cXG5jbGFzcyBBY3RvcklucHV0KEJhc2VNb2RlbCk6XFxuICAgIFxcXCJcXFwiXFxcIlRoZSBBY3RvciBpbnB1dCwgdmFsaWRhdGVkIHdpdGggZGVmYXVsdCB2YWx1ZXMuXFxcIlxcXCJcXFwiXFxuXFxuICAgIHBhY2thZ2U6IHN0ciA9ICdjcmF3bGVlJ1xcbiAgICBtb2RlbDogc3RyID0gJ29wZW5haS9ncHQtNS40LW1pbmknXFxuXFxuXFxuY2xhc3MgUGFja2FnZUZhY3RzKEJhc2VNb2RlbCk6XFxuICAgIFxcXCJcXFwiXFxcIlRoZSBtZXRhZGF0YSB0aGUgdG9vbCBwdWxscyBmcm9tIHRoZSBQeVBJIEpTT04gQVBJLlxcXCJcXFwiXFxcIlxcblxcbiAgICBuYW1lOiBzdHJcXG4gICAgdmVyc2lvbjogc3RyXFxuICAgIHN1bW1hcnk6IHN0ciB8IE5vbmVcXG4gICAgcmVxdWlyZXNfcHl0aG9uOiBzdHIgfCBOb25lXFxuXFxuXFxuY2xhc3MgUGFja2FnZVJlcG9ydChCYXNlTW9kZWwpOlxcbiAgICBcXFwiXFxcIlxcXCJUaGUgYWdlbnQncyB0eXBlZCB2ZXJkaWN0IG9uIGEgUHlQSSBwYWNrYWdlLlxcXCJcXFwiXFxcIlxcblxcbiAgICBuYW1lOiBzdHJcXG4gICAgbGF0ZXN0X3ZlcnNpb246IHN0clxcbiAgICBzdW1tYXJ5OiBzdHJcXG4gICAgcmVjb21tZW5kYXRpb246IHN0clxcblxcblxcbmFzeW5jIGRlZiBmZXRjaF9weXBpX21ldGFkYXRhKG5hbWU6IHN0cikgLT4gUGFja2FnZUZhY3RzOlxcbiAgICBcXFwiXFxcIlxcXCJGZXRjaCBhIHBhY2thZ2UncyBtZXRhZGF0YSBmcm9tIHRoZSBQeVBJIEpTT04gQVBJLlxcXCJcXFwiXFxcIlxcbiAgICBhc3luYyB3aXRoIGltcGl0LkFzeW5jQ2xpZW50KFxcbiAgICAgICAgYnJvd3Nlcj0nZmlyZWZveCcsIGZvbGxvd19yZWRpcmVjdHM9VHJ1ZSwgdGltZW91dD0zMFxcbiAgICApIGFzIGNsaWVudDpcXG4gICAgICAgIHJlc3BvbnNlID0gYXdhaXQgY2xpZW50LmdldChQWVBJX0pTT05fVVJMLmZvcm1hdChuYW1lPW5hbWUpKVxcblxcbiAgICBpZiByZXNwb25zZS5zdGF0dXNfY29kZSAhPSBIVFRQU3RhdHVzLk9LOlxcbiAgICAgICAgcmFpc2UgUnVudGltZUVycm9yKGYnUHlQSSBoYXMgbm8gcGFja2FnZSBuYW1lZCBcXFwie25hbWV9XFxcIi4nKVxcblxcbiAgICBpbmZvID0gcmVzcG9uc2UuanNvbigpWydpbmZvJ11cXG4gICAgcmV0dXJuIFBhY2thZ2VGYWN0cyhcXG4gICAgICAgIG5hbWU9aW5mb1snbmFtZSddLFxcbiAgICAgICAgdmVyc2lvbj1pbmZvWyd2ZXJzaW9uJ10sXFxuICAgICAgICBzdW1tYXJ5PWluZm9bJ3N1bW1hcnknXSxcXG4gICAgICAgIHJlcXVpcmVzX3B5dGhvbj1pbmZvWydyZXF1aXJlc19weXRob24nXSxcXG4gICAgKVxcblxcblxcbmFzeW5jIGRlZiBtYWluKCkgLT4gTm9uZTpcXG4gICAgYXN5bmMgd2l0aCBBY3RvcjpcXG4gICAgICAgICMgUGFyc2UgdGhlIEFjdG9yIGlucHV0IGludG8gdGhlIHR5cGVkIG1vZGVsLCBmaWxsaW5nIGluIGRlZmF1bHRzLlxcbiAgICAgICAgYWN0b3JfaW5wdXQgPSBBY3RvcklucHV0Lm1vZGVsX3ZhbGlkYXRlKGF3YWl0IEFjdG9yLmdldF9pbnB1dCgpIG9yIHt9KVxcbiAgICAgICAgcGFja2FnZSA9IGFjdG9yX2lucHV0LnBhY2thZ2VcXG4gICAgICAgIG1vZGVsID0gYWN0b3JfaW5wdXQubW9kZWxcXG5cXG4gICAgICAgICMgUm91dGUgdGhlIExMTSB0aHJvdWdoIHRoZSBBcGlmeSBPcGVuUm91dGVyIHByb3h5IChubyBwcm92aWRlciBrZXkgbmVlZGVkKS5cXG4gICAgICAgIHByb3ZpZGVyID0gT3BlbkFJUHJvdmlkZXIoXFxuICAgICAgICAgICAgYmFzZV91cmw9T1BFTlJPVVRFUl9CQVNFX1VSTCxcXG4gICAgICAgICAgICBhcGlfa2V5PW9zLmVudmlyb25bJ0FQSUZZX1RPS0VOJ10sXFxuICAgICAgICApXFxuXFxuICAgICAgICAjIGBvdXRwdXRfdHlwZWAgbWFrZXMgdGhlIGFnZW50IHJldHVybiBhIHZhbGlkYXRlZCBgUGFja2FnZVJlcG9ydGAuIFBhc3NpbmdcXG4gICAgICAgICMgYGZldGNoX3B5cGlfbWV0YWRhdGFgIGluIGB0b29sc2AgcmVnaXN0ZXJzIGl0IGFzIGEgdG9vbCBmcm9tIGl0cyBzaWduYXR1cmUuXFxuICAgICAgICBhZ2VudCA9IEFnZW50KFxcbiAgICAgICAgICAgIE9wZW5BSUNoYXRNb2RlbChtb2RlbCwgcHJvdmlkZXI9cHJvdmlkZXIpLFxcbiAgICAgICAgICAgIG91dHB1dF90eXBlPVBhY2thZ2VSZXBvcnQsXFxuICAgICAgICAgICAgdG9vbHM9W2ZldGNoX3B5cGlfbWV0YWRhdGFdLFxcbiAgICAgICAgICAgIHN5c3RlbV9wcm9tcHQ9KFxcbiAgICAgICAgICAgICAgICAnWW91IGFkdmlzZSBQeXRob24gZGV2ZWxvcGVycyBvbiBwYWNrYWdlcy4gQWx3YXlzIGNhbGwgJ1xcbiAgICAgICAgICAgICAgICAnYGZldGNoX3B5cGlfbWV0YWRhdGFgIGZvciBmYWN0cyBpbnN0ZWFkIG9mIGd1ZXNzaW5nLidcXG4gICAgICAgICAgICApLFxcbiAgICAgICAgKVxcblxcbiAgICAgICAgcHJvbXB0ID0gZidFdmFsdWF0ZSB0aGUgXFxcIntwYWNrYWdlfVxcXCIgcGFja2FnZSBhbmQgcmVjb21tZW5kIHdoZXRoZXIgdG8gdXNlIGl0LidcXG4gICAgICAgIHJlcG9ydCA9IChhd2FpdCBhZ2VudC5ydW4odXNlcl9wcm9tcHQ9cHJvbXB0KSkub3V0cHV0XFxuICAgICAgICBBY3Rvci5sb2cuaW5mbyhmJ1BhY2thZ2UgcmVwb3J0OlxcXFxue3JlcG9ydC5tb2RlbF9kdW1wX2pzb24oaW5kZW50PTIpfScpXFxuICAgICAgICBhd2FpdCBBY3Rvci5wdXNoX2RhdGEocmVwb3J0Lm1vZGVsX2R1bXAoKSlcXG5cXG5cXG5pZiBfX25hbWVfXyA9PSAnX19tYWluX18nOlxcbiAgICBhc3luY2lvLnJ1bihtYWluKCkpXFxuXCJ9Iiwib3B0aW9ucyI6eyJidWlsZCI6ImxhdGVzdCIsImNvbnRlbnRUeXBlIjoiYXBwbGljYXRpb24vanNvbjsgY2hhcnNldD11dGYtOCIsIm1lbW9yeSI6MTAyNCwidGltZW91dCI6MTgwfX0.7vz5_fabEu-eSpsZJ72-TOuaxcasNaS-SQN6-8V69TQ\&asrc=run_on_apify)

```
import asyncio

import os

from http import HTTPStatus



import impit

from pydantic import BaseModel

from pydantic_ai import Agent

from pydantic_ai.models.openai import OpenAIChatModel

from pydantic_ai.providers.openai import OpenAIProvider



from apify import Actor



OPENROUTER_BASE_URL = 'https://openrouter.apify.actor/api/v1'

PYPI_JSON_URL = 'https://pypi.org/pypi/{name}/json'





class ActorInput(BaseModel):

    """The Actor input, validated with default values."""



    package: str = 'crawlee'

    model: str = 'openai/gpt-5.4-mini'





class PackageFacts(BaseModel):

    """The metadata the tool pulls from the PyPI JSON API."""



    name: str

    version: str

    summary: str | None

    requires_python: str | None





class PackageReport(BaseModel):

    """The agent's typed verdict on a PyPI package."""



    name: str

    latest_version: str

    summary: str

    recommendation: str





async def fetch_pypi_metadata(name: str) -> PackageFacts:

    """Fetch a package's metadata from the PyPI JSON API."""

    async with impit.AsyncClient(

        browser='firefox', follow_redirects=True, timeout=30

    ) as client:

        response = await client.get(PYPI_JSON_URL.format(name=name))



    if response.status_code != HTTPStatus.OK:

        raise RuntimeError(f'PyPI has no package named "{name}".')



    info = response.json()['info']

    return PackageFacts(

        name=info['name'],

        version=info['version'],

        summary=info['summary'],

        requires_python=info['requires_python'],

    )





async def main() -> None:

    async with Actor:

        # Parse the Actor input into the typed model, filling in defaults.

        actor_input = ActorInput.model_validate(await Actor.get_input() or {})

        package = actor_input.package

        model = actor_input.model



        # Route the LLM through the Apify OpenRouter proxy (no provider key needed).

        provider = OpenAIProvider(

            base_url=OPENROUTER_BASE_URL,

            api_key=os.environ['APIFY_TOKEN'],

        )



        # `output_type` makes the agent return a validated `PackageReport`. Passing

        # `fetch_pypi_metadata` in `tools` registers it as a tool from its signature.

        agent = Agent(

            OpenAIChatModel(model, provider=provider),

            output_type=PackageReport,

            tools=[fetch_pypi_metadata],

            system_prompt=(

                'You advise Python developers on packages. Always call '

                '`fetch_pypi_metadata` for facts instead of guessing.'

            ),

        )



        prompt = f'Evaluate the "{package}" package and recommend whether to use it.'

        report = (await agent.run(user_prompt=prompt)).output

        Actor.log.info(f'Package report:\n{report.model_dump_json(indent=2)}')

        await Actor.push_data(report.model_dump())





if __name__ == '__main__':

    asyncio.run(main())
```

Note that:

* The agent reaches the LLM through the Apify OpenRouter proxy via `OpenAIChatModel` and a custom `OpenAIProvider`, so no provider API key is needed.
* Passing `fetch_pypi_metadata` in the `tools` list registers it as a tool. PydanticAI builds its schema from the function's signature and docstring, and the agent calls it to fetch real facts instead of guessing. Returning a typed `PackageFacts` keeps the data validated on its way back to the agent.
* `output_type=PackageReport` makes the agent return a validated `PackageReport`, which maps directly onto a dataset row. To get plain text back, set `output_type=str`.

## LlamaIndex[](#llamaindex)

[LlamaIndex](https://www.llamaindex.ai/) is built for retrieval-augmented agents that reason over your own data. It indexes your documents into a vector store, retrieves the passages relevant to a question, and grounds the answer in them. It's a strong fit for question answering over private or domain-specific content. To install it, use:

```
pip install \

    llama-index \

    llama-index-llms-openai-like \

    llama-index-embeddings-huggingface \

    pydantic
```

The install also includes [Pydantic](https://docs.pydantic.dev/) for the Actor's typed input and output models.

The following Actor builds a retrieval-augmented generation (RAG) pipeline. It scrapes a few pages with the [Website Content Crawler](https://apify.com/apify/website-content-crawler) Actor via `Actor.call`, wraps each crawled page in a `Document`, indexes the documents in a vector store, answers a question using only the retrieved passages, and cites its sources:

[Run on](https://console.apify.com/actors/HH9rhkFXiZbheuq1V?runConfig=eyJ1IjoiRWdQdHczb2VqNlRhRHQ1cW4iLCJ2IjoxfQ.eyJpbnB1dCI6IntcImNvZGVcIjpcImltcG9ydCBhc3luY2lvXFxuaW1wb3J0IG9zXFxuXFxuZnJvbSBsbGFtYV9pbmRleC5jb3JlIGltcG9ydCBEb2N1bWVudCwgVmVjdG9yU3RvcmVJbmRleFxcbmZyb20gbGxhbWFfaW5kZXguZW1iZWRkaW5ncy5odWdnaW5nZmFjZSBpbXBvcnQgSHVnZ2luZ0ZhY2VFbWJlZGRpbmdcXG5mcm9tIGxsYW1hX2luZGV4LmxsbXMub3BlbmFpX2xpa2UgaW1wb3J0IE9wZW5BSUxpa2VcXG5mcm9tIHB5ZGFudGljIGltcG9ydCBCYXNlTW9kZWxcXG5cXG5mcm9tIGFwaWZ5IGltcG9ydCBBY3Rvclxcblxcbk9QRU5ST1VURVJfQkFTRV9VUkwgPSAnaHR0cHM6Ly9vcGVucm91dGVyLmFwaWZ5LmFjdG9yL2FwaS92MSdcXG5FTUJFRF9NT0RFTCA9ICdCQUFJL2JnZS1zbWFsbC1lbi12MS41J1xcblxcblxcbmNsYXNzIEFjdG9ySW5wdXQoQmFzZU1vZGVsKTpcXG4gICAgXFxcIlxcXCJcXFwiVGhlIEFjdG9yIGlucHV0LCB2YWxpZGF0ZWQgd2l0aCBkZWZhdWx0IHZhbHVlcy5cXFwiXFxcIlxcXCJcXG5cXG4gICAgdXJsczogbGlzdFtzdHJdID0gW1xcbiAgICAgICAgJ2h0dHBzOi8vZG9jcy5hcGlmeS5jb20vcGxhdGZvcm0vYWN0b3JzJyxcXG4gICAgICAgICdodHRwczovL2RvY3MuYXBpZnkuY29tL3BsYXRmb3JtL3N0b3JhZ2UvZGF0YXNldCcsXFxuICAgICAgICAnaHR0cHM6Ly9kb2NzLmFwaWZ5LmNvbS9wbGF0Zm9ybS9wcm94eScsXFxuICAgIF1cXG4gICAgcXVlc3Rpb246IHN0ciA9ICdIb3cgZG9lcyBBcGlmeSBwcm94eSB3b3JrPydcXG4gICAgbW9kZWw6IHN0ciA9ICdvcGVuYWkvZ3B0LTUuNC1taW5pJ1xcblxcblxcbmNsYXNzIEFuc3dlcihCYXNlTW9kZWwpOlxcbiAgICBcXFwiXFxcIlxcXCJUaGUgZ3JvdW5kZWQgYW5zd2VyIHRoZSBxdWVyeSBlbmdpbmUgcmV0dXJucy5cXFwiXFxcIlxcXCJcXG5cXG4gICAgYW5zd2VyOiBzdHJcXG4gICAga2V5X2ZhY3RzOiBsaXN0W3N0cl1cXG5cXG5cXG5hc3luYyBkZWYgc2NyYXBlX2RvY3VtZW50cyh1cmxzOiBsaXN0W3N0cl0pIC0-IGxpc3RbRG9jdW1lbnRdOlxcbiAgICBcXFwiXFxcIlxcXCJTY3JhcGUgdGhlIHBhZ2VzIHdpdGggdGhlIFdlYnNpdGUgQ29udGVudCBDcmF3bGVyIGFuZCB3cmFwIHRoZW0gYXMgYERvY3VtZW50YHMuXFxcIlxcXCJcXFwiXFxuICAgIHJ1bl9pbnB1dCA9IHsnc3RhcnRVcmxzJzogW3sndXJsJzogdXJsfSBmb3IgdXJsIGluIHVybHNdLCAnbWF4Q3Jhd2xEZXB0aCc6IDB9XFxuICAgIHJ1biA9IGF3YWl0IEFjdG9yLmNhbGwoJ2FwaWZ5L3dlYnNpdGUtY29udGVudC1jcmF3bGVyJywgcnVuX2lucHV0PXJ1bl9pbnB1dClcXG4gICAgZGF0YXNldCA9IEFjdG9yLmFwaWZ5X2NsaWVudC5kYXRhc2V0KHJ1bi5kZWZhdWx0X2RhdGFzZXRfaWQpXFxuICAgIGl0ZW1zID0gKGF3YWl0IGRhdGFzZXQubGlzdF9pdGVtcygpKS5pdGVtc1xcbiAgICByZXR1cm4gW0RvY3VtZW50KHRleHQ9aXRlbVsndGV4dCddLCBtZXRhZGF0YT17J3VybCc6IGl0ZW1bJ3VybCddfSkgZm9yIGl0ZW0gaW4gaXRlbXNdXFxuXFxuXFxuYXN5bmMgZGVmIG1haW4oKSAtPiBOb25lOlxcbiAgICBhc3luYyB3aXRoIEFjdG9yOlxcbiAgICAgICAgIyBQYXJzZSB0aGUgQWN0b3IgaW5wdXQgaW50byB0aGUgdHlwZWQgbW9kZWwsIGZpbGxpbmcgaW4gZGVmYXVsdHMuXFxuICAgICAgICBhY3Rvcl9pbnB1dCA9IEFjdG9ySW5wdXQubW9kZWxfdmFsaWRhdGUoYXdhaXQgQWN0b3IuZ2V0X2lucHV0KCkgb3Ige30pXFxuICAgICAgICB1cmxzID0gYWN0b3JfaW5wdXQudXJsc1xcbiAgICAgICAgcXVlc3Rpb24gPSBhY3Rvcl9pbnB1dC5xdWVzdGlvblxcbiAgICAgICAgbW9kZWwgPSBhY3Rvcl9pbnB1dC5tb2RlbFxcblxcbiAgICAgICAgIyBSb3V0ZSB0aGUgTExNIHRocm91Z2ggdGhlIEFwaWZ5IE9wZW5Sb3V0ZXIgcHJveHkgKG5vIHByb3ZpZGVyIGtleSBuZWVkZWQpLlxcbiAgICAgICAgbGxtID0gT3BlbkFJTGlrZShcXG4gICAgICAgICAgICBtb2RlbD1tb2RlbCxcXG4gICAgICAgICAgICBhcGlfYmFzZT1PUEVOUk9VVEVSX0JBU0VfVVJMLFxcbiAgICAgICAgICAgIGFwaV9rZXk9b3MuZW52aXJvblsnQVBJRllfVE9LRU4nXSxcXG4gICAgICAgICAgICBpc19jaGF0X21vZGVsPVRydWUsXFxuICAgICAgICApXFxuXFxuICAgICAgICAjIEVtYmVkZGluZ3MgcnVuIGxvY2FsbHksIHNvIHRoZSBwcm94eSBuZWVkcyBubyBlbWJlZGRpbmdzIGVuZHBvaW50LiBMb2FkaW5nIHRoZVxcbiAgICAgICAgIyBtb2RlbCBibG9ja3MsIHNvIG9mZmxvYWQgaXQgd2l0aCBgYXN5bmNpby50b190aHJlYWRgLlxcbiAgICAgICAgZW1iZWRfbW9kZWwgPSBhd2FpdCBhc3luY2lvLnRvX3RocmVhZChcXG4gICAgICAgICAgICBIdWdnaW5nRmFjZUVtYmVkZGluZywgbW9kZWxfbmFtZT1FTUJFRF9NT0RFTFxcbiAgICAgICAgKVxcblxcbiAgICAgICAgIyBTY3JhcGUgdGhlIHBhZ2VzIGFuZCB3cmFwIGVhY2ggcmVzdWx0IGFzIGEgYERvY3VtZW50YCBmb3IgdGhlIHZlY3RvciBpbmRleC5cXG4gICAgICAgIGRvY3VtZW50cyA9IGF3YWl0IHNjcmFwZV9kb2N1bWVudHModXJscylcXG5cXG4gICAgICAgICMgQ2h1bmtpbmcgYW5kIGVtYmVkZGluZyBldmVyeSBkb2N1bWVudCBibG9ja3MgdG9vLCBzbyBvZmZsb2FkIGl0IHRoZSBzYW1lIHdheS5cXG4gICAgICAgIGluZGV4ID0gYXdhaXQgYXN5bmNpby50b190aHJlYWQoXFxuICAgICAgICAgICAgVmVjdG9yU3RvcmVJbmRleC5mcm9tX2RvY3VtZW50cywgZG9jdW1lbnRzLCBlbWJlZF9tb2RlbD1lbWJlZF9tb2RlbFxcbiAgICAgICAgKVxcblxcbiAgICAgICAgIyBgb3V0cHV0X2Nsc2AgcmV0dXJucyBhIHZhbGlkYXRlZCBgQW5zd2VyYC4gVGhlIHJlc3BvbnNlIHN0aWxsIGNhcnJpZXMgdGhlXFxuICAgICAgICAjIHJldHJpZXZlZCBgc291cmNlX25vZGVzYCwgc28gdGhlIGFuc3dlciBjYW4gY2l0ZSB0aGUgcGFnZXMgaXQgY2FtZSBmcm9tLlxcbiAgICAgICAgcXVlcnlfZW5naW5lID0gaW5kZXguYXNfcXVlcnlfZW5naW5lKFxcbiAgICAgICAgICAgIGxsbT1sbG0sXFxuICAgICAgICAgICAgb3V0cHV0X2Nscz1BbnN3ZXIsXFxuICAgICAgICAgICAgcmVzcG9uc2VfbW9kZT0nY29tcGFjdCcsXFxuICAgICAgICAgICAgc2ltaWxhcml0eV90b3Bfaz00LFxcbiAgICAgICAgKVxcbiAgICAgICAgcmVzcG9uc2UgPSBhd2FpdCBxdWVyeV9lbmdpbmUuYXF1ZXJ5KHF1ZXN0aW9uKVxcblxcbiAgICAgICAgYW5zd2VyID0gcmVzcG9uc2UucmVzcG9uc2VcXG4gICAgICAgIHNvdXJjZXMgPSBbbm9kZS5ub2RlLm1ldGFkYXRhWyd1cmwnXSBmb3Igbm9kZSBpbiByZXNwb25zZS5zb3VyY2Vfbm9kZXNdXFxuICAgICAgICByZWNvcmQgPSB7J3F1ZXN0aW9uJzogcXVlc3Rpb24sICoqYW5zd2VyLm1vZGVsX2R1bXAoKSwgJ3NvdXJjZXMnOiBzb3VyY2VzfVxcbiAgICAgICAgQWN0b3IubG9nLmluZm8oZidBbnN3ZXI6XFxcXG57YW5zd2VyLm1vZGVsX2R1bXBfanNvbihpbmRlbnQ9Mil9JylcXG4gICAgICAgIGF3YWl0IEFjdG9yLnB1c2hfZGF0YShyZWNvcmQpXFxuXFxuXFxuaWYgX19uYW1lX18gPT0gJ19fbWFpbl9fJzpcXG4gICAgYXN5bmNpby5ydW4obWFpbigpKVxcblwifSIsIm9wdGlvbnMiOnsiYnVpbGQiOiJsYXRlc3QiLCJjb250ZW50VHlwZSI6ImFwcGxpY2F0aW9uL2pzb247IGNoYXJzZXQ9dXRmLTgiLCJtZW1vcnkiOjEwMjQsInRpbWVvdXQiOjE4MH19.ahrD9f5PmuloX7cTxmPwGnqm4klTd5TtWdeU_LOAebA\&asrc=run_on_apify)

```
import asyncio

import os



from llama_index.core import Document, VectorStoreIndex

from llama_index.embeddings.huggingface import HuggingFaceEmbedding

from llama_index.llms.openai_like import OpenAILike

from pydantic import BaseModel



from apify import Actor



OPENROUTER_BASE_URL = 'https://openrouter.apify.actor/api/v1'

EMBED_MODEL = 'BAAI/bge-small-en-v1.5'





class ActorInput(BaseModel):

    """The Actor input, validated with default values."""



    urls: list[str] = [

        'https://docs.apify.com/platform/actors',

        'https://docs.apify.com/platform/storage/dataset',

        'https://docs.apify.com/platform/proxy',

    ]

    question: str = 'How does Apify proxy work?'

    model: str = 'openai/gpt-5.4-mini'





class Answer(BaseModel):

    """The grounded answer the query engine returns."""



    answer: str

    key_facts: list[str]





async def scrape_documents(urls: list[str]) -> list[Document]:

    """Scrape the pages with the Website Content Crawler and wrap them as `Document`s."""

    run_input = {'startUrls': [{'url': url} for url in urls], 'maxCrawlDepth': 0}

    run = await Actor.call('apify/website-content-crawler', run_input=run_input)

    dataset = Actor.apify_client.dataset(run.default_dataset_id)

    items = (await dataset.list_items()).items

    return [Document(text=item['text'], metadata={'url': item['url']}) for item in items]





async def main() -> None:

    async with Actor:

        # Parse the Actor input into the typed model, filling in defaults.

        actor_input = ActorInput.model_validate(await Actor.get_input() or {})

        urls = actor_input.urls

        question = actor_input.question

        model = actor_input.model



        # Route the LLM through the Apify OpenRouter proxy (no provider key needed).

        llm = OpenAILike(

            model=model,

            api_base=OPENROUTER_BASE_URL,

            api_key=os.environ['APIFY_TOKEN'],

            is_chat_model=True,

        )



        # Embeddings run locally, so the proxy needs no embeddings endpoint. Loading the

        # model blocks, so offload it with `asyncio.to_thread`.

        embed_model = await asyncio.to_thread(

            HuggingFaceEmbedding, model_name=EMBED_MODEL

        )



        # Scrape the pages and wrap each result as a `Document` for the vector index.

        documents = await scrape_documents(urls)



        # Chunking and embedding every document blocks too, so offload it the same way.

        index = await asyncio.to_thread(

            VectorStoreIndex.from_documents, documents, embed_model=embed_model

        )



        # `output_cls` returns a validated `Answer`. The response still carries the

        # retrieved `source_nodes`, so the answer can cite the pages it came from.

        query_engine = index.as_query_engine(

            llm=llm,

            output_cls=Answer,

            response_mode='compact',

            similarity_top_k=4,

        )

        response = await query_engine.aquery(question)



        answer = response.response

        sources = [node.node.metadata['url'] for node in response.source_nodes]

        record = {'question': question, **answer.model_dump(), 'sources': sources}

        Actor.log.info(f'Answer:\n{answer.model_dump_json(indent=2)}')

        await Actor.push_data(record)





if __name__ == '__main__':

    asyncio.run(main())
```

Note that:

* `OpenAILike` points `api_base` at the Apify OpenRouter proxy, so no provider API key is needed. It's the LlamaIndex LLM class for any OpenAI-compatible endpoint.
* `Document` wraps each scraped page, and `VectorStoreIndex.from_documents` chunks and embeds them. The index is the retrieval layer that sets LlamaIndex apart from a plain agent loop.
* Embeddings run locally through `HuggingFaceEmbedding` from the `llama-index-embeddings-huggingface` package, so the proxy needs no embeddings endpoint.
* The embedding model downloads on the first run and the Website Content Crawler renders pages in a browser, so this pipeline is heavier than the other examples.
* `index.as_query_engine(output_cls=Answer)` retrieves the passages most relevant to the question, then the LLM composes a validated `Answer` grounded in them.
* Every response carries the passages it used in `response.source_nodes`, so the Actor pushes their `url` metadata as citations. `Actor.call` runs the Website Content Crawler, and `to_document` maps each dataset item to a `Document`, so indexing more content is a matter of crawling more pages. For details, see [Using Apify Actors as tools](#using-apify-actors-as-tools).

## Smolagents[](#smolagents)

[Smolagents](https://huggingface.co/docs/smolagents) is a lightweight framework whose agents write and run Python code to solve a task, instead of emitting structured tool calls. Writing code lets the agent chain several operations, loop, and transform data in a single step. It's a good fit for tasks that map cleanly to code. To install it, use:

```
pip install "smolagents[openai]" pydantic impit
```

The install also includes [Pydantic](https://docs.pydantic.dev/) for the Actor's typed input and output models, plus [Impit](https://github.com/apify/impit), the HTTP client the Actor uses to call external APIs.

The following Actor runs a `CodeAgent` that writes Python to analyze the Hacker News front page. Its `fetch_front_page` tool uses [Impit](https://github.com/apify/impit) to call the Hacker News API, then the agent groups the stories by domain and returns a structured breakdown of the busiest ones:

[Run on](https://console.apify.com/actors/HH9rhkFXiZbheuq1V?runConfig=eyJ1IjoiRWdQdHczb2VqNlRhRHQ1cW4iLCJ2IjoxfQ.eyJpbnB1dCI6IntcImNvZGVcIjpcImltcG9ydCBhc3luY2lvXFxuaW1wb3J0IG9zXFxuXFxuaW1wb3J0IGltcGl0XFxuZnJvbSBweWRhbnRpYyBpbXBvcnQgQmFzZU1vZGVsLCBWYWxpZGF0aW9uRXJyb3JcXG5mcm9tIHNtb2xhZ2VudHMgaW1wb3J0IENvZGVBZ2VudCwgT3BlbkFJU2VydmVyTW9kZWwsIHRvb2xcXG5cXG5mcm9tIGFwaWZ5IGltcG9ydCBBY3Rvclxcblxcbk9QRU5ST1VURVJfQkFTRV9VUkwgPSAnaHR0cHM6Ly9vcGVucm91dGVyLmFwaWZ5LmFjdG9yL2FwaS92MSdcXG5ITl9GUk9OVF9QQUdFX1VSTCA9ICdodHRwczovL2huLmFsZ29saWEuY29tL2FwaS92MS9zZWFyY2g_dGFncz1mcm9udF9wYWdlJmhpdHNQZXJQYWdlPTUwJ1xcblRPUF9ET01BSU5TID0gNVxcblxcblxcbmNsYXNzIEFjdG9ySW5wdXQoQmFzZU1vZGVsKTpcXG4gICAgXFxcIlxcXCJcXFwiVGhlIEFjdG9yIGlucHV0LCB2YWxpZGF0ZWQgd2l0aCBkZWZhdWx0IHZhbHVlcy5cXFwiXFxcIlxcXCJcXG5cXG4gICAgbW9kZWw6IHN0ciA9ICdvcGVuYWkvZ3B0LTUuNC1taW5pJ1xcblxcblxcbmNsYXNzIERvbWFpblN0YXQoQmFzZU1vZGVsKTpcXG4gICAgXFxcIlxcXCJcXFwiT25lIGRvbWFpbidzIHNsaWNlIG9mIHRoZSBIYWNrZXIgTmV3cyBmcm9udCBwYWdlLlxcXCJcXFwiXFxcIlxcblxcbiAgICBkb21haW46IHN0clxcbiAgICBzdG9yeV9jb3VudDogaW50XFxuICAgIGF2ZXJhZ2VfcG9pbnRzOiBmbG9hdFxcblxcblxcbmNsYXNzIEZyb250UGFnZVJlcG9ydChCYXNlTW9kZWwpOlxcbiAgICBcXFwiXFxcIlxcXCJUaGUgc3RydWN0dXJlZCBhbmFseXNpcyB0aGUgYWdlbnQgY29tcHV0ZXMgZnJvbSB0aGUgZnJvbnQgcGFnZS5cXFwiXFxcIlxcXCJcXG5cXG4gICAgdG90YWxfc3RvcmllczogaW50XFxuICAgIHRvcF9kb21haW5zOiBsaXN0W0RvbWFpblN0YXRdXFxuXFxuXFxuQHRvb2xcXG5kZWYgZmV0Y2hfZnJvbnRfcGFnZSgpIC0-IGxpc3RbZGljdF06XFxuICAgIFxcXCJcXFwiXFxcIkZldGNoIHRoZSBIYWNrZXIgTmV3cyBmcm9udCBwYWdlIGFzIHN0b3J5IGRpY3RzIHdpdGggdGl0bGUsIHVybCwgYW5kIHBvaW50cy5cXFwiXFxcIlxcXCJcXG4gICAgd2l0aCBpbXBpdC5DbGllbnQoYnJvd3Nlcj0nZmlyZWZveCcsIGZvbGxvd19yZWRpcmVjdHM9VHJ1ZSwgdGltZW91dD0zMCkgYXMgY2xpZW50OlxcbiAgICAgICAgaGl0cyA9IGNsaWVudC5nZXQoSE5fRlJPTlRfUEFHRV9VUkwpLmpzb24oKVsnaGl0cyddXFxuXFxuICAgIHJldHVybiBbXFxuICAgICAgICB7J3RpdGxlJzogaGl0Wyd0aXRsZSddLCAndXJsJzogaGl0Wyd1cmwnXSwgJ3BvaW50cyc6IGhpdFsncG9pbnRzJ119XFxuICAgICAgICBmb3IgaGl0IGluIGhpdHNcXG4gICAgICAgIGlmIGhpdC5nZXQoJ3VybCcpIGFuZCBoaXQuZ2V0KCdwb2ludHMnKSBpcyBub3QgTm9uZVxcbiAgICBdXFxuXFxuXFxuZGVmIGlzX3ZhbGlkX3JlcG9ydChmaW5hbF9hbnN3ZXI6IG9iamVjdCwgKl86IG9iamVjdCwgKipfXzogb2JqZWN0KSAtPiBib29sOlxcbiAgICBcXFwiXFxcIlxcXCJDaGVjayB0aGUgYWdlbnQncyBhbnN3ZXIgYWdhaW5zdCBgRnJvbnRQYWdlUmVwb3J0YCBzbyB0aGUgc2NoZW1hIGlzIGVuZm9yY2VkLlxcXCJcXFwiXFxcIlxcbiAgICB0cnk6XFxuICAgICAgICBGcm9udFBhZ2VSZXBvcnQubW9kZWxfdmFsaWRhdGUoZmluYWxfYW5zd2VyKVxcbiAgICBleGNlcHQgVmFsaWRhdGlvbkVycm9yOlxcbiAgICAgICAgcmV0dXJuIEZhbHNlXFxuICAgIHJldHVybiBUcnVlXFxuXFxuXFxuYXN5bmMgZGVmIG1haW4oKSAtPiBOb25lOlxcbiAgICBhc3luYyB3aXRoIEFjdG9yOlxcbiAgICAgICAgIyBQYXJzZSB0aGUgQWN0b3IgaW5wdXQgaW50byB0aGUgdHlwZWQgbW9kZWwsIGZpbGxpbmcgaW4gZGVmYXVsdHMuXFxuICAgICAgICBhY3Rvcl9pbnB1dCA9IEFjdG9ySW5wdXQubW9kZWxfdmFsaWRhdGUoYXdhaXQgQWN0b3IuZ2V0X2lucHV0KCkgb3Ige30pXFxuICAgICAgICBtb2RlbCA9IGFjdG9yX2lucHV0Lm1vZGVsXFxuXFxuICAgICAgICAjIFJvdXRlIHRoZSBMTE0gdGhyb3VnaCB0aGUgQXBpZnkgT3BlblJvdXRlciBwcm94eSAobm8gcHJvdmlkZXIga2V5IG5lZWRlZCkuXFxuICAgICAgICBsbG0gPSBPcGVuQUlTZXJ2ZXJNb2RlbChcXG4gICAgICAgICAgICBtb2RlbF9pZD1tb2RlbCxcXG4gICAgICAgICAgICBhcGlfYmFzZT1PUEVOUk9VVEVSX0JBU0VfVVJMLFxcbiAgICAgICAgICAgIGFwaV9rZXk9b3MuZW52aXJvblsnQVBJRllfVE9LRU4nXSxcXG4gICAgICAgIClcXG5cXG4gICAgICAgICMgQSBgQ29kZUFnZW50YCBzb2x2ZXMgdGhlIHRhc2sgYnkgd3JpdGluZyBhbmQgcnVubmluZyBQeXRob24sIHNvIGl0IGdyb3Vwc1xcbiAgICAgICAgIyBhbmQgYXZlcmFnZXMgdGhlIHN0b3JpZXMgaW4gY29kZS4gYGZpbmFsX2Fuc3dlcl9jaGVja3NgIHJlLXJ1bnNcXG4gICAgICAgICMgYGlzX3ZhbGlkX3JlcG9ydGAgb24gZWFjaCBhbnN3ZXIgdW50aWwgdGhlIG91dHB1dCB2YWxpZGF0ZXMuXFxuICAgICAgICBhZ2VudCA9IENvZGVBZ2VudChcXG4gICAgICAgICAgICB0b29scz1bZmV0Y2hfZnJvbnRfcGFnZV0sXFxuICAgICAgICAgICAgbW9kZWw9bGxtLFxcbiAgICAgICAgICAgIGFkZGl0aW9uYWxfYXV0aG9yaXplZF9pbXBvcnRzPVsnY29sbGVjdGlvbnMnLCAnc3RhdGlzdGljcycsICd1cmxsaWIucGFyc2UnXSxcXG4gICAgICAgICAgICBmaW5hbF9hbnN3ZXJfY2hlY2tzPVtpc192YWxpZF9yZXBvcnRdLFxcbiAgICAgICAgKVxcblxcbiAgICAgICAgcHJvbXB0ID0gKFxcbiAgICAgICAgICAgICdBbmFseXplIHRoZSBjdXJyZW50IEhhY2tlciBOZXdzIGZyb250IHBhZ2UuIENhbGwgYGZldGNoX2Zyb250X3BhZ2VgLCAnXFxuICAgICAgICAgICAgJ3RoZW4gdXNlIGNvZGUgdG8gdGFrZSB0aGUgZG9tYWluIG9mIGVhY2ggc3RvcnkgVVJMLCBncm91cCB0aGUgc3RvcmllcyAnXFxuICAgICAgICAgICAgZidieSBkb21haW4sIGFuZCBmaW5kIHRoZSB7VE9QX0RPTUFJTlN9IGRvbWFpbnMgd2l0aCB0aGUgbW9zdCBzdG9yaWVzLiAnXFxuICAgICAgICAgICAgJ0NhbGwgYGZpbmFsX2Fuc3dlcmAgd2l0aCBhIGRpY3Qgc2hhcGVkIGxpa2Uge1xcXCJ0b3RhbF9zdG9yaWVzXFxcIjogaW50LCAnXFxuICAgICAgICAgICAgJ1xcXCJ0b3BfZG9tYWluc1xcXCI6IFt7XFxcImRvbWFpblxcXCI6IHN0ciwgXFxcInN0b3J5X2NvdW50XFxcIjogaW50LCAnXFxuICAgICAgICAgICAgJ1xcXCJhdmVyYWdlX3BvaW50c1xcXCI6IGZsb2F0fV19LidcXG4gICAgICAgIClcXG4gICAgICAgICMgYENvZGVBZ2VudC5ydW5gIGlzIHN5bmNocm9ub3VzLCBzbyBgYXN5bmNpby50b190aHJlYWRgIGtlZXBzIGl0IG9mZiB0aGVcXG4gICAgICAgICMgQWN0b3IncyBldmVudCBsb29wLlxcbiAgICAgICAgcmVzdWx0ID0gYXdhaXQgYXN5bmNpby50b190aHJlYWQoYWdlbnQucnVuLCBwcm9tcHQpXFxuXFxuICAgICAgICByZXBvcnQgPSBGcm9udFBhZ2VSZXBvcnQubW9kZWxfdmFsaWRhdGUocmVzdWx0KVxcbiAgICAgICAgQWN0b3IubG9nLmluZm8oZidGcm9udCBwYWdlIHJlcG9ydDpcXFxcbntyZXBvcnQubW9kZWxfZHVtcF9qc29uKGluZGVudD0yKX0nKVxcbiAgICAgICAgYXdhaXQgQWN0b3IucHVzaF9kYXRhKHJlcG9ydC5tb2RlbF9kdW1wKCkpXFxuXFxuXFxuaWYgX19uYW1lX18gPT0gJ19fbWFpbl9fJzpcXG4gICAgYXN5bmNpby5ydW4obWFpbigpKVxcblwifSIsIm9wdGlvbnMiOnsiYnVpbGQiOiJsYXRlc3QiLCJjb250ZW50VHlwZSI6ImFwcGxpY2F0aW9uL2pzb247IGNoYXJzZXQ9dXRmLTgiLCJtZW1vcnkiOjEwMjQsInRpbWVvdXQiOjE4MH19.ulHg4aE5Bz6dNfZMVkvITK_Br7bAOLvMWrQxWGCo4h8\&asrc=run_on_apify)

```
import asyncio

import os



import impit

from pydantic import BaseModel, ValidationError

from smolagents import CodeAgent, OpenAIServerModel, tool



from apify import Actor



OPENROUTER_BASE_URL = 'https://openrouter.apify.actor/api/v1'

HN_FRONT_PAGE_URL = 'https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=50'

TOP_DOMAINS = 5





class ActorInput(BaseModel):

    """The Actor input, validated with default values."""



    model: str = 'openai/gpt-5.4-mini'





class DomainStat(BaseModel):

    """One domain's slice of the Hacker News front page."""



    domain: str

    story_count: int

    average_points: float





class FrontPageReport(BaseModel):

    """The structured analysis the agent computes from the front page."""



    total_stories: int

    top_domains: list[DomainStat]





@tool

def fetch_front_page() -> list[dict]:

    """Fetch the Hacker News front page as story dicts with title, url, and points."""

    with impit.Client(browser='firefox', follow_redirects=True, timeout=30) as client:

        hits = client.get(HN_FRONT_PAGE_URL).json()['hits']



    return [

        {'title': hit['title'], 'url': hit['url'], 'points': hit['points']}

        for hit in hits

        if hit.get('url') and hit.get('points') is not None

    ]





def is_valid_report(final_answer: object, *_: object, **__: object) -> bool:

    """Check the agent's answer against `FrontPageReport` so the schema is enforced."""

    try:

        FrontPageReport.model_validate(final_answer)

    except ValidationError:

        return False

    return True





async def main() -> None:

    async with Actor:

        # Parse the Actor input into the typed model, filling in defaults.

        actor_input = ActorInput.model_validate(await Actor.get_input() or {})

        model = actor_input.model



        # Route the LLM through the Apify OpenRouter proxy (no provider key needed).

        llm = OpenAIServerModel(

            model_id=model,

            api_base=OPENROUTER_BASE_URL,

            api_key=os.environ['APIFY_TOKEN'],

        )



        # A `CodeAgent` solves the task by writing and running Python, so it groups

        # and averages the stories in code. `final_answer_checks` re-runs

        # `is_valid_report` on each answer until the output validates.

        agent = CodeAgent(

            tools=[fetch_front_page],

            model=llm,

            additional_authorized_imports=['collections', 'statistics', 'urllib.parse'],

            final_answer_checks=[is_valid_report],

        )



        prompt = (

            'Analyze the current Hacker News front page. Call `fetch_front_page`, '

            'then use code to take the domain of each story URL, group the stories '

            f'by domain, and find the {TOP_DOMAINS} domains with the most stories. '

            'Call `final_answer` with a dict shaped like {"total_stories": int, '

            '"top_domains": [{"domain": str, "story_count": int, '

            '"average_points": float}]}.'

        )

        # `CodeAgent.run` is synchronous, so `asyncio.to_thread` keeps it off the

        # Actor's event loop.

        result = await asyncio.to_thread(agent.run, prompt)



        report = FrontPageReport.model_validate(result)

        Actor.log.info(f'Front page report:\n{report.model_dump_json(indent=2)}')

        await Actor.push_data(report.model_dump())





if __name__ == '__main__':

    asyncio.run(main())
```

Note that:

* `OpenAIServerModel` points `api_base` at the Apify OpenRouter proxy, so no provider API key is needed.
* Writing code is what a `CodeAgent` does best. It calls `fetch_front_page`, then writes Python to take each URL's domain, group the stories, and average their points. `additional_authorized_imports` grants the standard-library modules that code needs.
* `final_answer_checks` validates every candidate answer against `FrontPageReport`. A mismatch sends the agent back to fix its code, so the output stays structured rather than free-form. The returned dict is then parsed with `FrontPageReport.model_validate`.
* `CodeAgent.run` is synchronous, so `asyncio.to_thread` runs it without blocking the Actor's event loop.
* Because a `CodeAgent` executes the code it generates, run it in the isolated Actor container rather than on your own machine.

## Using Apify Actors as tools[](#using-apify-actors-as-tools)

An agent becomes useful once it can act on the world. On Apify, any of the thousands of Actors in [Apify Store](https://apify.com/store) can become a tool: a scraper that fetches data, a browser automation that fills a form, or an API wrapper that posts a message.

Some frameworks ship a ready-made wrapper for turning an Actor into a tool. For LangGraph and other [LangChain](https://www.langchain.com/)-based agents, the [`langchain-apify`](https://github.com/apify/langchain-apify) package exposes any Actor as a tool with a single line:

```
from langchain_apify import ApifyActorsTool



# Let the agent scrape Instagram profiles by calling the apify/instagram-scraper Actor.

tools = [ApifyActorsTool('apify/instagram-scraper')]
```

For any other framework, call the Actor with `Actor.call` and read its dataset through the preconfigured API client:

```
async def scrape_instagram(handle: str) -> list[dict]:

    """Scrape recent posts from an Instagram profile."""

    run_input = {'directUrls': [f'https://www.instagram.com/{handle}/']}

    run = await Actor.call('apify/instagram-scraper', run_input=run_input)

    return (await Actor.apify_client.dataset(run.default_dataset_id).list_items()).items
```

The [CrewAI](#crewai) example wraps this pattern in a `BaseTool` so its agent can call the Actor.

For details on calling other Actors, see [Interacting with other Actors](https://docs.apify.com/sdk/python/sdk/python/docs/concepts/interacting-with-other-actors.md).

## Running on the Apify platform[](#running-on-the-apify-platform)

Agents run on the standard [Apify Python base image](https://hub.docker.com/r/apify/actor-python), so no browser or extra system dependencies are needed. Add `apify` and the packages required by your framework to `requirements.txt`, then deploy the Actor as usual.

To monetize the agent, use [pay-per-event charging](https://docs.apify.com/sdk/python/sdk/python/docs/concepts/pay-per-event.md). You define events such as `task-completed` in the Actor's monetization settings and trigger them from the code:

```
await Actor.charge('task-completed')
```

This approach lets you charge users directly from the Actor and cover the cost of execution and LLM tokens. It pairs well with the Apify OpenRouter proxy, which already bills the LLM usage to the account running the Actor.

## Conclusion[](#conclusion)

In this guide, you learned how to host an AI agent as an Apify Actor. You can now build an agent with LangGraph, CrewAI, PydanticAI, LlamaIndex, or Smolagents, connect it to an LLM through the Apify OpenRouter proxy, give it Apify Actors as tools, and monetize it with pay-per-event. To get started, see the [Actor templates](https://apify.com/templates/categories/python). If you have questions or need assistance, feel free to reach out on our [GitHub](https://github.com/apify/apify-sdk-python) or join our [Discord community](https://discord.com/invite/jyEM2PRvMU). Happy building!

## Additional resources[](#additional-resources)

* [Apify: LangGraph template](https://apify.com/templates/python-langgraph)
* [Apify: CrewAI template](https://apify.com/templates/python-crewai)
* [Apify: PydanticAI template](https://apify.com/templates/python-pydanticai)
* [Apify: LlamaIndex template](https://apify.com/templates/python-llamaindex-agent)
* [Apify: Smolagents template](https://apify.com/templates/python-smolagents)
* [Apify: actor-templates repository](https://github.com/apify/actor-templates)
* [Apify: OpenRouter proxy](https://apify.com/apify/openrouter)
* [Apify: langchain-apify integration](https://github.com/apify/langchain-apify)
* [Apify blog: What are AI agents](https://blog.apify.com/what-are-ai-agents/)
