Integrations

CrewAI

Build a CrewAI crew whose research is grounded in your Aether document store — and whose findings flow back into it.

CrewAI orchestrates multi-agent workflows where each agent has a role and a set of tools. This guide builds a two-agent crew: a researcher that searches your Aether store and saves what it learns, and a writer that turns those findings into a finished brief. Because the researcher writes its findings back with insert_text, every run leaves the knowledge base a little richer — the next crew can retrieve what the last one discovered.


Prerequisites

Install the Aether SDK alongside CrewAI.

Python
pip install aether-ai crewai

Package vs. import name

The package is published as aether-ai, but the import is unchanged: from aether import AetherClient.

Environment variables

Configuration

Set the following environment variables before running the example below.

  • OPENAI_API_KEY — your OpenAI API key (required; CrewAI uses OpenAI by default, and can be configured for other providers — see the CrewAI documentation).
  • AETHER_API_KEY — your Aether API key, loaded and passed to the client below (optional for unauthenticated deployments).

Define the tools

CrewAI tools subclass BaseTool: you declare a name, a description the LLM uses to decide when to call the tool, and an args_schema (a Pydantic model) describing the inputs. The actual work happens in _run().

Two tools cover the loop. KnowledgeBaseSearch wraps retrieve(), which returns full document content ready to drop into an agent's context. SaveFindings wraps insert_text() and tags everything it writes with crew:research, so saved findings are easy to filter for later.

Python
import os

from aether import AetherClient
from crewai.tools import BaseTool
from pydantic import BaseModel, Field

aether = AetherClient(api_key=os.environ.get("AETHER_API_KEY"))


class SearchInput(BaseModel):
    query: str = Field(description="A natural-language search query.")


class KnowledgeBaseSearch(BaseTool):
    name: str = "knowledge_base_search"
    description: str = (
        "Search the company knowledge base and return the most "
        "relevant documents for a natural-language query."
    )
	args_schema: type[BaseModel] = SearchInput

	def _run(self, query: str) -> str:
	    results = [
	        r for r in aether.retrieve(query, k=10)
	        if r.score >= 60
	    ][:5]
	    if not results:
	        return "No relevant documents found."
	    return "\n\n".join(
	        f"[{r.title or r.doc_id}] (score {r.score:.3f})\n{r.content}"
	        for r in results
	    )


class FindingsInput(BaseModel):
    findings: str = Field(
        description="Research findings worth keeping for future runs."
    )


class SaveFindings(BaseTool):
    name: str = "save_findings"
    description: str = (
        "Save research findings to the knowledge base so future "
        "crews can build on them."
    )
    args_schema: type[BaseModel] = FindingsInput

    def _run(self, findings: str) -> str:
        record = aether.insert_text(
            findings,
            filename="crew-research-notes.txt",
            tags=["crew:research"],
        )
        return f"Findings saved as document {record.doc_id}."

A few deliberate choices here. BaseTool is itself a Pydantic model, so the tools reference a module-level aether client rather than holding it as a field — that sidesteps field-validation boilerplate for non-Pydantic types. The score >= 60 cutoff drops weak matches before they reach the agent; treat it as a starting point and tune it against your own data (see Tuning retrieval). And tag values must not contain commas — they're sent comma-joined on the wire — so stick to key:value slugs like crew:research.

Wire up the crew

The researcher gets both tools. The writer gets none — it works purely from the researcher's output, which CrewAI passes along via the task context. Tasks run sequentially by default.

Python
from crewai import Agent, Crew, Task

researcher = Agent(
    role="Research Analyst",
    goal="Find accurate, well-sourced answers in the knowledge base",
    backstory=(
        "You dig through the company knowledge base, verify what you "
        "find, and record useful findings for future research."
    ),
    tools=[KnowledgeBaseSearch(), SaveFindings()],
)

writer = Agent(
    role="Technical Writer",
    goal="Turn research notes into a clear, concise brief",
    backstory=(
        "You write plain-language summaries from research notes, "
        "without adding claims the research does not support."
    ),
)

research_task = Task(
    description=(
        "Research how the company onboards new enterprise customers: "
        "key steps, owners, and typical timelines. Save a summary of "
        "your findings to the knowledge base before finishing."
    ),
    expected_output="Bullet-point research notes with sources.",
    agent=researcher,
)

writing_task = Task(
    description="Write a one-page onboarding brief from the research notes.",
    expected_output="A structured brief with headings, under 500 words.",
    agent=writer,
    context=[research_task],
)

crew = Crew(agents=[researcher, writer], tasks=[research_task, writing_task])
result = crew.kickoff()
print(result)

At runtime the researcher calls knowledge_base_search as many times as it needs, calls save_findings once it's satisfied, and hands its notes to the writer. The saved document is a regular Aether document — you can retrieve() it from any future crew, chain, or application.

Scoping crew memory per user or customer

If your crew runs on behalf of different users or customers, don't let their research mix. Tags handle this: add a tenant tag when saving, and pass the same tags as a filter when retrieving. Tag filters are ANDed — a document must carry every listed tag to match.

Python
scope = ["crew:research", f"customer:{customer_id}"]

# Save into this customer's slice of the store
aether.insert_text(findings, filename="crew-notes.txt", tags=scope)

# Recall only this customer's research
notes = [
    r for r in aether.retrieve(query, k=10, tags=scope)
    if r.score >= 60
]

When a tag matches only a small slice of your documents, a filtered search can return fewer than k results even though more matches exist. So when filtering to a narrow tag, request a larger k than you actually need and filter the weak tail by score.

Tags are write-only

Tags are not returned on document records or search results — you can filter by them, but you can't read them back. If you need to enumerate or audit which tags a document carries, keep your own doc_id → tags mapping in your application.

For the full pattern — tag conventions, isolation guarantees, and what tags do and don't protect against — see Multi-tenant patterns.