An all-in-one Python AI framework for building agents, chatbots, RAG apps, and workflows
Project description
Runora
An all-in-one Python AI framework for building agents, chatbots, RAG apps, workflows, and APIs — without the overhead of heavy libraries.
pip install runora
from runora import Agent
agent = Agent(name="Bot", model="mock:test", instructions="You are helpful.")
print(agent.ask("Hello!").text)
No API key required to get started.
Table of contents
- What is Runora?
- Why Runora?
- Installation
- Quickstart
- Basic chatbot
- RAG docs bot
- Tool-calling agent
- Serving as an API
- Memory
- Evals
- Tracing
- CLI commands
- Environment variables
- API key safety
- Development install
- Publishing to PyPI
- Roadmap
What is Runora?
Runora is a lightweight Python framework that gives you everything you need to build production-ready AI applications:
| Component | What it does |
|---|---|
| Agent | Conversational AI with tools, memory, and a knowledge base |
| Tool | Any Python function the agent can call autonomously |
| Knowledge | Local RAG — chunk and retrieve .txt/.md files, no embeddings API |
| Memory | Conversation buffer with a configurable message window |
| Workflow | Directed acyclic pipeline connecting agents and functions |
| Serve | One-line FastAPI wrapper — any agent becomes an HTTP API |
| CLI | runora new, runora chat, runora serve, runora eval |
| Evals | JSONL datasets, pass/fail metrics, saved reports |
| Tracing | Every call traced to .runora/traces/ with cost estimation |
Why Runora?
| Runora | LangChain | bare OpenAI SDK | |
|---|---|---|---|
| Lines of code for a basic agent | ~5 | ~20+ | ~30+ |
| No API key required | ✅ mock provider | ❌ | ❌ |
| Built-in RAG (no embeddings API) | ✅ | needs setup | ❌ |
| Built-in serving | ✅ one call | needs FastAPI | ❌ |
| Built-in evals | ✅ | plugin | ❌ |
| Built-in CLI | ✅ | ❌ | ❌ |
| Import-time secrets | ❌ never | sometimes | sometimes |
| Production-ready tracing | ✅ | needs setup | ❌ |
Design principles:
- Zero secrets at import time — API keys are loaded only when
ask()is first called - No magic — every component is a plain Python class you can read, subclass, or replace
- Mock-first development —
model="mock:test"works everywhere so you can build and test without an API key - Minimal dependencies — Pydantic, FastAPI, Typer, Rich. That is all.
Installation
# Core (mock provider included)
pip install runora
# With OpenAI support
pip install "runora[openai]"
# With uv
uv add runora
uv add "runora[openai]"
Requires Python 3.11+.
Quickstart
from runora import Agent
# Works immediately — no API key, no setup
agent = Agent(
name="Assistant",
model="mock:test",
instructions="You are a concise, helpful assistant.",
)
response = agent.ask("What is Runora?")
print(response.text) # the answer
print(response.trace_id) # UUID — every call is traced
print(response.cost_usd) # 0.0 for mock; real $ for OpenAI
Swap to a real model by changing one line — no other code changes required:
agent = Agent(name="Assistant", model="openai:gpt-4.1-mini", instructions="...")
Basic chatbot
from runora import Agent
from runora.memory import Memory
agent = Agent(
name="ChatBot",
model="mock:test", # swap for "openai:gpt-4.1-mini"
instructions="You are a friendly assistant.",
memory=Memory(max_messages=20), # remember the last 20 turns
)
while True:
user_input = input("You: ").strip()
if user_input.lower() in ("exit", "quit"):
break
response = agent.ask(user_input)
print(f"Bot: {response.text}")
Or scaffold a project in one command:
runora new my-chatbot
cd my-chatbot
python app.py
RAG docs bot
Load local documents into a keyword-based retrieval index. No embeddings API needed.
from runora import Agent, Knowledge
# Load all .txt and .md files — chunked and indexed automatically
kb = Knowledge.from_folder("./docs", chunk_size=800, overlap=120)
agent = Agent(
name="Docs Bot",
model="openai:gpt-4.1-mini",
instructions="Answer questions using only the provided documentation.",
knowledge=kb,
)
response = agent.ask("How do I get started?")
print(response.text)
print("Sources:", response.sources) # list of file paths that were retrieved
How it works:
Knowledge.from_folder()reads every.txtand.mdfile recursively- Files are split into overlapping chunks (configurable
chunk_sizeandoverlap) - On each
ask(), the most relevant chunks are retrieved via keyword scoring - Retrieved chunks are injected into the system prompt as context
- Source file paths are returned in
response.sources
Supported file types: .txt, .md
Tool-calling agent
Decorate any Python function with @tool and the agent can call it autonomously.
from typing import Annotated
from runora import Agent, tool
@tool
def get_order_status(
order_id: Annotated[str, "The order ID, e.g. ORD-001"],
) -> str:
"""Look up the current status of an order."""
# call your real database or API here
return f"Order {order_id}: Shipped. Estimated delivery: tomorrow."
@tool
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email to a customer."""
# your email logic here
return f"Email sent to {to}."
agent = Agent(
name="Support Agent",
model="openai:gpt-4.1-mini",
instructions="You are a customer support agent. Use tools to look up orders and send emails.",
tools=[get_order_status, send_email],
)
response = agent.ask("What's the status of order ORD-042? Email the customer if it's delayed.")
print(response.text)
print("Tool calls:", response.tool_calls)
# [{"name": "get_order_status", "args": {"order_id": "ORD-042"}, "result": "..."}]
How it works:
@toolinspects the function signature and generates a JSON schema from type annotations- On each turn, Runora appends available tool descriptions to the system prompt
- The model emits
<tool_call name="...">{"arg": "value"}</tool_call>when it wants to call a tool - Runora parses and executes the call, then feeds the result back to the model
- The loop runs up to 3 iterations per
ask()call
Dangerous tools (require user confirmation in future versions):
@tool(dangerous=True)
def delete_record(record_id: str) -> str:
"""Permanently delete a database record."""
...
Serving as an API
Turn any agent into a FastAPI HTTP server with one call.
from runora import Agent, serve
agent = Agent(
name="API Bot",
model="openai:gpt-4.1-mini",
instructions="You are a helpful assistant.",
)
serve(agent, host="0.0.0.0", port=8787)
Or from the command line:
runora serve app.py --port 8787
Endpoints:
| Method | Path | Description |
|---|---|---|
POST |
/chat |
Send a message, get a response |
GET |
/health |
Liveness probe |
GET |
/agent |
Agent metadata (name, model, tools) |
GET |
/traces/{id} |
Retrieve a trace by ID |
Chat request / response:
curl -X POST http://localhost:8787/chat \
-H "Content-Type: application/json" \
-d '{"message": "Hello"}'
{
"text": "Hello! How can I help you today?",
"agent": "API Bot",
"model": "openai:gpt-4.1-mini",
"provider": "openai",
"trace_id": "a1b2c3d4-...",
"sources": [],
"tool_calls": [],
"cost_usd": 0.000042
}
Use create_app() to get the FastAPI instance for testing or integration:
from runora.serve import create_app
from fastapi.testclient import TestClient
app = create_app(agent)
client = TestClient(app)
resp = client.post("/chat", json={"message": "Hi"})
Memory
Agents can maintain conversation history across turns.
from runora import Agent
from runora.memory import Memory
agent = Agent(
name="Bot",
model="openai:gpt-4.1-mini",
instructions="You are a helpful assistant.",
memory=Memory(max_messages=40), # rolling window of last 40 messages
)
agent.ask("My name is Alice.")
agent.ask("What languages do I use?")
response = agent.ask("What's my name?")
print(response.text) # "Your name is Alice."
Persistent fact memory — store and recall named facts across sessions:
from runora import Agent, LocalMemory
mem = LocalMemory(path=".runora/memory.json") # persisted to disk
mem.add("User is a senior Python developer.", tags=["background"])
mem.add("User prefers brief answers.", tags=["style"])
agent = Agent(
name="Bot",
model="openai:gpt-4.1-mini",
instructions="You are a personalized assistant.",
local_memory=mem,
)
response = agent.ask("How experienced am I?")
print(response.text) # grounded in the stored facts
| Class | Use case | Storage |
|---|---|---|
Memory |
Conversation history (current session) | In-process |
LocalMemory |
Named facts (across sessions) | JSON file |
Evals
Test your agent against a dataset before shipping.
1. Create a JSONL dataset (tests/cases.jsonl):
{"input": "What is the refund policy?", "expected": "30 days"}
{"input": "How do I reset my password?", "expected_keywords": ["email", "reset", "link"]}
{"input": "What is 2 + 2?", "expected": "4"}
2. Run evals:
from runora import Agent
from runora.evals import run_eval, EvalReport, save_report
agent = Agent(name="SupportBot", model="openai:gpt-4.1-mini", instructions="...")
run = run_eval(agent, "tests/cases.jsonl", metric="contains_expected")
print(f"Pass rate: {run.pass_rate:.0%}") # e.g. "Pass rate: 67%"
report = EvalReport.from_run(run)
save_report(report) # saved to .runora/evals/eval_20260613T….json
3. Or from the CLI:
runora eval app.py --dataset tests/cases.jsonl --metric keyword_match --save
Available metrics:
| Metric | Passes when |
|---|---|
contains_expected |
expected appears anywhere in the output |
exact_match |
output equals expected exactly (case-insensitive) |
keyword_match |
all expected_keywords appear in the output |
JSONL fields:
| Field | Required for |
|---|---|
input |
all metrics |
expected |
contains_expected, exact_match |
expected_keywords |
keyword_match |
Tracing
Every agent.ask() call is automatically traced.
response = agent.ask("Tell me about Paris.")
print(response.trace_id) # "a1b2c3d4-e5f6-..."
Traces are saved as JSON under .runora/traces/<trace_id>.json:
{
"trace_id": "a1b2c3d4-...",
"timestamp": "2026-06-13T10:00:00Z",
"agent": "MyBot",
"model": "openai:gpt-4.1-mini",
"provider": "openai",
"input": "Tell me about Paris.",
"output": "Paris is the capital of France...",
"tool_calls": [],
"sources": [],
"cost_usd": 0.000085
}
CLI — inspect traces:
runora trace last # most recent trace
runora trace a1b2c3d4 # specific trace by ID
Programmatic access:
from runora.tracing.store import load_trace, load_last_trace
data = load_last_trace()
print(data["output"])
print(f"Cost: ${data['cost_usd']:.6f}")
Workflow traces are also saved with "type": "workflow" so they are easy to distinguish.
CLI commands
runora version # print installed version
runora new my-project # scaffold basic project
runora new my-rag --template rag # scaffold RAG project
runora new my-bot --template tool # scaffold tool agent project
runora chat app.py # interactive REPL with your agent
runora ask app.py "What is Runora?" # one-shot query
runora serve app.py # serve on http://127.0.0.1:8787
runora serve app.py --port 9000 # custom port
runora serve app.py --host 0.0.0.0 # expose to network
runora trace last # print the most recent trace
runora trace <id> # print a specific trace
runora eval app.py \
--dataset tests/cases.jsonl \
--metric keyword_match \
--save # run evals and save report
runora doctor # environment health check
runora doctor checks:
- Python version (≥ 3.11 required)
.envfile existsOPENAI_API_KEYis set (value never printed).runora/directory is writable
Project templates (runora new):
| Template | Contents |
|---|---|
basic (default) |
app.py, .env.example, README.md |
rag |
above + docs/intro.md |
tool |
app.py with a sample @tool |
Environment variables
Create a .env file in your project root:
OPENAI_API_KEY=sk-...
OPENAI_BASE_URL= # optional: custom endpoint (Ollama, proxy, etc.)
Runora reads these automatically when ask() is first called. Never at import time.
Custom base URL — point Runora at any OpenAI-compatible API:
OPENAI_API_KEY=ollama # or any non-empty string
OPENAI_BASE_URL=http://localhost:11434/v1
agent = Agent(name="Local", model="openai:llama3.2", instructions="...")
Model string formats:
"mock:test" # built-in mock, no key needed
"openai:gpt-4.1-mini" # OpenAI GPT-4.1 Mini
"openai:gpt-4o" # OpenAI GPT-4o
"gpt-4o-mini" # shorthand — resolved to OpenAI automatically
API key safety
Runora is designed so that secrets never leak accidentally.
What Runora guarantees:
- API keys are never read at import time — only when
ask()is first called - API keys are never written to trace files, logs, or any output
- The
OPENAI_API_KEYis loaded once per process from the environment — it is never stored on theAgentobject runora doctorchecks whether the key is set but never prints its value
What you must do:
Warning Never commit
.envto GitHub. Add it to.gitignoreimmediately.
echo ".env" >> .gitignore
Your .env.example (safe to commit — no real values):
OPENAI_API_KEY=
OPENAI_BASE_URL=
If you accidentally commit a key:
- Rotate the key immediately at platform.openai.com/api-keys
- Remove it from git history:
git filter-repoor BFG Repo Cleaner - Force-push the cleaned history
Development install
Clone the repo and install in editable mode so changes to the source are reflected immediately without reinstalling:
git clone https://github.com/runora-ai/runora.git
cd runora
# With uv (recommended)
uv pip install -e ".[dev,openai]"
# With pip
pip install -e ".[dev,openai]"
The dev extra installs pytest, pytest-asyncio, and ruff.
Run the test suite:
pytest
# or with uv's managed Python
uv run pytest
Run the linter / formatter:
ruff check . # lint
ruff format . # format
ruff check --fix . # auto-fix lint issues
Run a single example:
python examples/basic_chatbot/app.py
python examples/tool_agent/app.py
python examples/workflow_blog_writer/app.py
Publishing to PyPI
Note: These instructions are for maintainers cutting a release. End users install Runora with
pip install runora.
Prerequisites:
# Install uv if you don't have it
curl -LsSf https://astral.sh/uv/install.sh | sh
# Create a PyPI account and generate an API token at:
# https://pypi.org/manage/account/token/
1. Bump the version in pyproject.toml:
[project]
version = "0.1.0" # change to the new version
Update CHANGELOG.md with the new release entry.
2. Build the distribution packages:
uv build
This creates two files in dist/:
dist/
runora-0.1.0-py3-none-any.whl # wheel (fast install)
runora-0.1.0.tar.gz # source distribution
3. Verify the build locally before uploading:
# Install into a temporary venv and smoke-test
uv venv /tmp/runora-test
/tmp/runora-test/bin/pip install dist/runora-0.1.0-py3-none-any.whl
/tmp/runora-test/bin/python -c "from runora import Agent; print(Agent('Bot','mock:test','hi').ask('hi').text)"
/tmp/runora-test/bin/runora version
4. Publish to PyPI:
uv publish
uv publish reads your PyPI token from UV_PUBLISH_TOKEN env var or
~/.pypi/credentials. You can also pass it directly:
UV_PUBLISH_TOKEN=pypi-AgAAA... uv publish
Publish to TestPyPI first (recommended for new releases):
uv publish --index https://test.pypi.org/legacy/ \
--index-url https://test.pypi.org/simple/
Then verify the install from TestPyPI:
pip install --index-url https://test.pypi.org/simple/ runora==0.1.0
Roadmap
v0.1.0 — current
- Agent with sync/async
ask()and agentic tool-calling loop - Mock provider (zero config, no API key)
- OpenAI provider (
gpt-4.1-mini,gpt-4o, and allgpt-*models) @tooldecorator with automatic JSON schema generation- Local RAG (
Knowledge.from_folder) — keyword retrieval, no embeddings API - Conversation memory (
Memory) and persistent fact memory (LocalMemory) Workflow— directed acyclic pipeline of agents and functions with cycle detectionserve()— FastAPI wrapper with/chat,/health,/agent,/traces/{id}- CLI:
new,chat,ask,serve,trace,doctor,eval - Evals: JSONL datasets,
contains_expected/exact_match/keyword_matchmetrics - Tracing: JSON traces with cost estimation saved to
.runora/traces/
v0.2.0 — planned
- Streaming responses (
agent.stream()) - Anthropic / Claude provider
- Gemini provider
- Semantic / embedding-based retrieval (optional upgrade from keyword)
- Async workflow execution with parallel branches
-
LLM-as-judgeeval metric - Web UI for trace inspection (
runora ui)
v0.3.0 — ideas
- Agent-to-agent handoff (multi-agent routing)
- Long-term vector memory (Chroma, Pinecone)
- Structured output (Pydantic response models)
- Function schemas from OpenAPI specs
- Deployment to Fly.io, Railway, Render (one command)
License
MIT — free to use in personal and commercial projects.
Built with Python 3.11+. No LangChain. No magic.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file runora-0.1.0.tar.gz.
File metadata
- Download URL: runora-0.1.0.tar.gz
- Upload date:
- Size: 126.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
937fcf66f9c700a07a72d8a2a95ff23345216584df33b3d982ecefea350a79fd
|
|
| MD5 |
442a3ebf501c4f36c04ce578724a7b95
|
|
| BLAKE2b-256 |
78f1341788862c4ab0a9e8b84105a7ec780d6a1b8931ba18d2929fb9cb71a3f0
|
File details
Details for the file runora-0.1.0-py3-none-any.whl.
File metadata
- Download URL: runora-0.1.0-py3-none-any.whl
- Upload date:
- Size: 63.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aa2210ed2938b29e8f3a20cec0f6999138df7223c54a450b44f49b9363c8eab6
|
|
| MD5 |
9b7c7ed370073de1f35943e2a32c53fd
|
|
| BLAKE2b-256 |
c10fa1a92517afd266dc7d03fe02d16c3692251ddc5fed861cb602de10857384
|