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', ...]
- 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_actionsis a pre-loaded starter pack;html_selectandget_text_from_listare individual actions. Any of your own functions can become an action with the@actiondecorator. - 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.
- The docstring tells the agent what to do. Since this task is going to be executed by an agent, the body stays empty.
- 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 callingtop_hacker_news_stories.run().
This is a shortened version of a runnable example in the repo:
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)!
Recipe.to_coderenders 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)!
- An
Actionis 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 |