Skip to content

Quickstart — three real applications

The front-page example proves the idea (evidence → answer, no graph wired between the two agents) in the smallest possible shape. This page bridges the gap to something you'd actually build: three short, runnable snippets — a tool-calling agent, retrieval over your own docs, and a session-persisted chat bot — each pointing at the full, production-shaped example it's trimmed from. Every snippet on this page was run to produce the output shown; none of it is hypothetical.

0. The 80% on-ramp: reactifact.quick

Most first tasks are one of four things. reactifact.quick wires each of them in a few lines, while still building real reactive agents under the hood:

from pydantic import BaseModel
from reactifact.quick import agent, rag, tools_agent, chat_agent


class AnswerBody(BaseModel):
    text: str


# 1) one structured LLM call
qa = agent(system="Answer in one sentence.", schema=AnswerBody)
body = await qa.ask("What are the three states of water?")   # AnswerBody | None

# 2) retrieval over your own files, with provenance
r = rag({"docs": "./docs", "costs": "./costs.csv"})
answer = await r.ask("what's the total gpu cost?")           # answer.text, answer.sources

# 3) an LLM with tools (human=True → asks clarifying questions instead)
t = tools_agent("You are ops. Use the tools.", [check_status])  # a @tool (§1)
text = await t.ask("is checkout-api healthy?")

# 4) a session-persisted chat assistant (store=None → in-memory)
assistant = chat_agent(agents=[...], llm=provider)

Every entry point takes llm= — any LLMProvider (from_env(), openai_llm(...), openrouter_llm(...), …). It defaults to from_env(), so a configured .env just works; pass it explicitly to choose a model/endpoint.

This is sugar over the same primitives, not a second framework. Every object exposes the real Agent and the Context the run produced:

qa.agent      # the reactive Agent — mount it on your own Runtime
qa.context    # the artifacts/provenance of the last ask()

rag(...) links every Answer supported_by the Docs it used, and each Doc materialized_from its SourceRef — the same provenance the hand-written pipeline below builds. When the facade no longer fits, drop to Consume/Produce/Effects with nothing to rewrite. See Patterns for the full model.

Prefer a notebook? Open in Colabexamples/quickstart.ipynb walks the same four cases, offline by default.

Bring your own artifact models — the facade is parameterized, not fixed:

r = rag(
    {"docs": "./docs"},
    doc_type=MyDoc,          # must accept text/locator/title…
    answer_type=MyAnswer,
    # …or build them yourself and say how to read a doc's body/label:
    doc_factory=lambda ctx, ref, content: MyDoc(body=content, url=ref.data.locator),
    answer_factory=lambda text, docs: MyAnswer(answer=text, citations=[d.data.url for d in docs]),
    doc_text=lambda d: d.body,
    doc_locator=lambda d: d.url,
)
qa = agent("Answer briefly.", AnswerBody, question_type=MyQuestion)  # needs a `text` field

Provenance is unaffected by the model shape: an answer is still linked supported_by the documents it used.

1. A tool-calling agent (with human-in-the-loop)

HITLLMAgent wires an LLM + a Tool list into the reactive ask/tool/answer loop for you — the model decides whether to call a tool, ask the user a clarifying question, or answer, one step at a time (§60). No manual "if missing, ask" branch anywhere in your own code:

import asyncio
from pydantic import BaseModel

from reactifact import Consume, Context, Runtime, RuntimeResources
from reactifact.llm_agent import HITLLMAgent
from reactifact.providers import from_env
from reactifact.tools import tool


class Ticket(BaseModel):
    text: str


@tool
async def check_status(service: str) -> str:
    """Look up the current status of a service."""
    return f"{service}: healthy, 3 replicas, 12ms p50 latency"


class OpsAgent(HITLLMAgent):
    name = "ops"
    system = "You are an ops assistant. Use check_status to answer questions about services."
    tools = [check_status]
    consumes = [Consume(Ticket)]


async def main():
    ctx = Context(resources=RuntimeResources(llm=from_env()))
    runtime = Runtime(ctx, agents=[OpsAgent()])
    ctx.create(Ticket(text="is checkout-api healthy?"))
    await runtime.arun()

    from reactifact.tool_use import ToolAnswer
    answer = ctx.latest(ToolAnswer)
    print("answer:", answer.data.text if answer else None)


asyncio.run(main())

No API key configured (from_env() returns None) → the agent honestly says "Could not reach a decision." instead of guessing (§59) — that's not a bug, it's the same honest-fallback contract every generative step in reactifact follows. With a key in .env, the model actually decides to call check_status and answers from its result. To see the full loop run deterministically offline (no key, no guessing), swap resources.llm for a scripted LLMProvider the way tests/test_tools.py's ScriptedLLM does, or reach for reactifact.testing.ScenarioLab if you're writing this as a test.

Full version: examples/devops — three specialist agents (k8s/GitLab/ Ansible) behind one router, a trace dashboard, a web UI. Run it: .venv/bin/python examples/devops/chat.py.

2. Retrieval over your own docs

Sources are a capability, not a hardcoded RAG pipeline — FileSystemSource here, but CSVSource/EmbeddingSource/WebSource plug into the exact same fan_out_sources call (§8). Provenance is a first-class effect (materialize_doc links the derived Doc back to its SourceRef), not a citation string built by hand:

import asyncio
from pydantic import BaseModel

from reactifact import Consume, Context, Runtime, RuntimeResources, create_agent
from reactifact.produce import produce
from reactifact.recipes import fan_out_sources, materialize_doc, find, keyword_score
from reactifact.sources import FileSystemSource, SourceRef


class Question(BaseModel):
    text: str


class Doc(BaseModel):
    text: str


class Answer(BaseModel):
    text: str
    sources: list[str] = []


@produce(SourceRef)
async def search(call):
    question = find(call.inputs, Question)
    if question is None:
        return None
    await fan_out_sources(call.context, question.data.text, owner_id=question.id, limit=3)


@produce(Doc)
async def resolve(call):
    ref = find(call.inputs, SourceRef)
    if ref is None:
        return None
    await materialize_doc(call.context, ref, lambda ctx, ref, content: Doc(text=content))


@produce(Answer)
async def answer(call):
    docs = [a for a in call.inputs if isinstance(a.data, Doc)]
    if not docs:
        return None
    d = docs[0]
    sources = call.context.related(d.id, "materialized_from")
    return Answer(text=d.data.text.strip(), sources=[s.data.locator for s in sources])


search_agent = create_agent("search", consumes=[Consume(Question)], produces=[search])
resolve_agent = create_agent("resolve", consumes=[Consume(SourceRef)], produces=[resolve])
answer_agent = create_agent("answer", consumes=[Consume(Doc)], produces=[answer])


async def main():
    resources = RuntimeResources(
        # the dict key and source_id= must match — that's how a SourceRef
        # finds its way back to the source that produced it (resources.get_source)
        sources={"docs": FileSystemSource("./docs", source_id="docs", scorer=keyword_score)},
    )
    ctx = Context(resources=resources)
    runtime = Runtime(ctx, agents=[search_agent, resolve_agent, answer_agent])
    ctx.create(Question(text="what is your refunds policy?"))
    await runtime.arun()
    a = ctx.latest(Answer)
    print("answer:", a.data.text if a else None)
    print("sources:", a.data.sources if a else None)


asyncio.run(main())

Two easy-to-hit gotchas this snippet already avoids: pass scorer=keyword_score — the plain default scorer has no stop-word filtering, so a query like "what is your refunds policy?" can rank a wrong document up on the word "is" alone; and give FileSystemSource a source_id= that matches the dict key you register it under in resources.sources, since that's the only thing that lets a later materialize_doc resolve a SourceRef back to the source that produced it.

Full version: examples/knowledge — file + CSV sources, evidence extraction, claim verification, deterministic calculation, a keyword-triggered skill. Run it: .venv/bin/python examples/knowledge/chat.py.

3. A session-persisted chat bot

ChatAssistant owns sessions, the turn loop, and history reconstruction — your app supplies only the domain hooks. The same object works as a plain async call (invoke) or, mounted via reactifact.web.create_chat_router, as an SSE endpoint on your own FastAPI app:

import asyncio
from pydantic import BaseModel

from reactifact import Consume, RuntimeResources, SessionStore, create_agent
from reactifact.checkpoints import FileKVBackend
from reactifact.chat import ChatAssistant
from reactifact.produce import produce


class UserMsg(BaseModel):
    text: str
    session_id: str = ""


class Reply(BaseModel):
    query_id: str
    text: str


@produce(Reply)
async def echo(call):
    msg = call.trigger
    if msg is None:
        return None
    return Reply(query_id=msg.id, text=f"you said: {msg.data.text}")


echo_agent = create_agent("echo", consumes=[Consume(UserMsg)], produces=[echo])


def reply(ctx, msg_id):
    latest = ctx.latest(Reply)
    return {"reply": latest.data.text if latest else ""}


async def main():
    store = SessionStore(FileKVBackend("./sessions"))
    assistant = ChatAssistant(
        store=store,
        agents=[echo_agent],
        user_message=UserMsg,
        reply=reply,
        resources=RuntimeResources,  # a fresh RuntimeResources per turn
    )
    result = await assistant.invoke("hello there", session_id="demo")
    print(result)                                    # {'reply': 'you said: hello there'}
    print(await assistant.history(session_id="demo"))  # both turns, reconstructed


asyncio.run(main())

Reopen the process and call assistant.invoke(..., session_id="demo") again — the conversation resumes exactly where it left off, because FileKVBackend persisted the whole commit chain, not just the latest reply. To serve this over HTTP instead of calling invoke directly:

from fastapi import FastAPI
from reactifact.web import create_chat_router

app = FastAPI()
app.include_router(create_chat_router(assistant))
# POST /api/chat/stream, GET/DELETE /api/runs/{session_id} — see docs/en/api.md

Full version: examples/devops or examples/knowledge's web.py — real agents behind the same two calls, plus a trace dashboard. resources= there is a callable (lambda: build_resources()) exactly like this snippet, for the same reason: ChatAssistant closes a callable-built RuntimeResources after every turn automatically, so a real provider's HTTP client never leaks.

Where this leaves you

Each snippet above is the smallest reactive version of its pattern — no step of the pipeline calls the next one directly; every step only declares what it consumes/produces, and the runtime derives execution from state changes. That's the same rule the full examples run on, just with more agents, more sources, more error paths. From here:

  • Patterns — more shapes (reflection, map-reduce, supervisor, …).
  • Recipes — the building blocks this page used (fan_out_sources, materialize_doc, find) plus the ones it didn't (StatusMachine, WindowSummarizer, Skill).
  • Examples — all fourteen, with what each one specifically teaches.
  • Port matrix — if you know LangGraph/CrewAI, which example maps to which pattern you already know.