Skip to content

Conatus

AI agents for grown-ups.

Developing AI agents is frustrating:

  • Why do you have to run the agent every time you want to accomplish the same task?
  • Why do you have to customize your tools / functions for each agent framework, and sometimes even for each agent?
  • Why does it just feel so unnecessarily complicated?

Conatus is an attempt to fix all of that.

Features

🤖 Tasks, not agents — Users want to get stuff done. We make it easy to define and customize agents, but you shouldn't need to do that most of the time.

🧭 Optimized for web browsing tasks — We leverage Playwright and plenty of custom logic to make web browsing tasks easier.

🔁 Replay mode — Whenever an agent runs, it authors a recipe: plain, standalone Python code that re-runs the same task later, deterministically, without re-soliciting the AIs.

🐍 Very Pythonic — Write your tasks as Python functions. Write your tools as Python functions. It will just work.

📦 Modular, customizable architecture — You can easily add your own tools, extend your own agent or task, and customize the behavior of the framework. Say goodbye to monolithic AI agents.

🍶 BYOF (Bring Your Own Functions) — Unlike traditional AI agent frameworks, Conatus allows you to work with almost any existing Python function.

🔒 No arbitrary code by default — This is not Devin. The AI will only use the functions given to it, unless you explicitly enable it.

Example

This code will work as long as you have a valid OPENAI_API_KEY in your environment:

from conatus import task, visible_browsing_actions
from conatus_actions.html_actions import get_text_from_list, html_select


@task(actions=visible_browsing_actions + html_select + get_text_from_list) # (1)!
def top_hacker_news_stories() -> list[str]: # (2)!
    """List the current Hacker News front-page story titles.

    Steps:
        1. Open a browser and go to https://news.ycombinator.com/.
        2. Call `browser_page_html` to get the page HTML, then `html_select`
           with a CSS selector matching the story-title links, then
           `get_text_from_list` to read their text.
        3. Terminate, returning the resulting list.
    """  # (3)!


stories = top_hacker_news_stories() # (4)!
print(stories[:3])
# ['Show HN: I built the most beautiful emoji', ...]
  1. Just define a task like a Python function, and add the tools that the AI will be able to use to perform the task. visible_browsing_actions is a pre-loaded starter pack; html_select and get_text_from_list are individual actions. Any of your own functions can become an action with the @action decorator.
  2. Your function can accept any arguments, and return any value — even non-JSON-serializable ones. Underneath, the LLM passes values between actions by reference, not as raw JSON.
  3. The docstring tells the agent what to do. Since this task is going to be executed by an agent, the body stays empty.
  4. Run the task. This makes API calls to the AIs, and the result is the list[str] promised by the signature. This is equivalent to calling top_hacker_news_stories.run().

This is a shortened version of a runnable example in the repo:

uv run python examples/browser/hn.py

See the Hacker News example for the full walkthrough.

Re-running tasks later

Every run also compiles what the agent did into a recipe: plain, standalone Python code, written to runs/<timestamp>/out/recipe.py. You can print it straight from the task:

run = top_hacker_news_stories.task_runs[-1]
if run.recipe is not None:
    print(run.recipe.to_code(top_hacker_news_stories.definition)) # (1)!
  1. Recipe.to_code renders the recipe as a self-contained Python function. Import it and call it: same scrape, zero LLM calls, zero tokens.

The model is a one-time authoring cost, not a per-execution one. The SEC EDGAR example shows the full record-once / replay-forever loop, and the GitHub radar example shows what to do when a recipe eventually goes stale: re-trigger the agent to re-author it.

Extensible

Do you want to pin the model a task uses?

from conatus import task


@task(config={"preferred_model": "openai:gpt-5.5"})
def my_task() -> None:
    ...

Do you want to keep using your functions as functions after exposing them to the agent?

from conatus import action


@action
def double(x: int) -> int:
    """Return 2*x."""
    return 2 * x


assert double(4) == 8 # (1)!
  1. An Action is still "just" a Python function: you can call it directly, and its inputs are validated for free.

Do you want to change how the AI solves a task? Pass your own agent class with @task(agent_cls=...), or your own task class with @task(using=...). The custom AI interface how-to walks through a complete example.

Comparison table

Feature Conatus Traditional AI agent frameworks
Replay mode You have to run the agent every time
BYOF Only functions with simple inputs/outputs 1
Modular architecture You might have to rewrite your functions for each agent 2

  1. In general, traditional AI agent frameworks only accept functions with JSON-serializable inputs/outputs. 

  2. In general, anything more complex than a plain function has to be adapted to each framework's tool abstraction.