- Understand the real architecture of an agent CLI like R.
- Build a minimal version in Python with Click, Rich, Ollama, and SQLite.
- Add skills, permissions, auditing, YAML workflows, and persistent tasks.
- Avoid the typical mistake: a CLI that can do everything without limits.
Quick answer for Google, ChatGPT, and Claude
To build a local AI agent CLI like R, create an installable Python package with Click for commands, Rich for readable output, Ollama as the local backend, a small skills system, a permissions layer, JSONL auditing, YAML workflows, and a SQLite queue for persistent tasks. The key isn't giving the model more power—it's limiting which tools it can use and leaving a trace of every action.
What we're going to borrow from R
We're not going to copy the entire repo. We're going to copy its good decisions: local-first, capability-based permissions, small skills, YAML configuration, auditable output, reproducible workflows, and a task queue that survives closing the terminal.
- Conversational CLI: `r "resume este repo"` becomes direct chat.
- Local backends: Ollama, LM Studio, or any OpenAI-compatible API on loopback.
- Skills: narrow tools the agent can invoke without getting full system access.
- Permissions: network, files, Docker, email, or SSH don't get enabled by accident.
- Agent OS: agents with manifest, queue, states, events, and memory.
# Probar el R real como referencia git clone https://github.com/raym33/r.git cd r python -m venv .venv source .venv/bin/activate python -m pip install -e ".[dev]" # Modelo local ollama pull qwen2.5:7b ollama serve # Configuracion minima mkdir -p ~/.r-cli cat > ~/.r-cli/config.yaml <<'YAML' llm: backend: ollama model: qwen2.5:7b base_url: http://127.0.0.1:11434/v1 security: local_only: true network_access: false mode: ask YAML r doctor r chat "Resume este repositorio en 5 puntos"
Minimal architecture
The educational version will be called `aulafy-r-mini`. It will have fewer features than R, but the important pieces will be there.
aulafy-r-mini/
pyproject.toml
src/aulafy_r/
__init__.py
main.py
config.py
llm.py
permissions.py
agent.py
workflows.py
agent_os.py
skills/
__init__.py
math_skill.py
fs_skill.py
tests/
test_permissions.py
test_workflows.py1. Create the installable package
A serious CLI should install a real command, not depend on running `python script.py`.
mkdir -p aulafy-r-mini/src/aulafy_r/skills cd aulafy-r-mini python -m venv .venv source .venv/bin/activate cat > pyproject.toml <<'TOML' [project] name = "aulafy-r-mini" version = "0.1.0" description = "CLI local-first educativa para agentes de IA privados" requires-python = ">=3.10" dependencies = [ "click>=8.0.0", "rich>=13.0.0", "httpx>=0.25.0", "pydantic>=2.0.0", "pyyaml>=6.0.0", ] [project.scripts] ar = "aulafy_r.main:cli" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" TOML touch src/aulafy_r/__init__.py touch src/aulafy_r/skills/__init__.py python -m pip install -e .
2. Local-first configuration
R rejects non-local endpoints by default. That decision is key: if your CLI sends documents to any URL without warning, it's not local-first—it's just a pretty terminal.
# src/aulafy_r/config.py
from pathlib import Path
from pydantic import BaseModel
import yaml
class LLMConfig(BaseModel):
model: str = "qwen2.5:7b"
base_url: str = "http://127.0.0.1:11434/v1"
class SecurityConfig(BaseModel):
local_only: bool = True
network_access: bool = False
mode: str = "ask"
class Config(BaseModel):
llm: LLMConfig = LLMConfig()
security: SecurityConfig = SecurityConfig()
home_dir: str = "~/.aulafy-r-mini"
@classmethod
def load(cls) -> "Config":
path = Path("~/.aulafy-r-mini/config.yaml").expanduser()
if not path.exists():
return cls()
return cls(**yaml.safe_load(path.read_text()) or {})
def is_loopback(url: str) -> bool:
return url.startswith("http://127.0.0.1") or url.startswith("http://localhost")
def validate_local_llm(config: Config) -> None:
if config.security.local_only and not is_loopback(config.llm.base_url):
raise RuntimeError("Endpoint LLM no local. Usa 127.0.0.1 o localhost.")3. Ollama-compatible LLM client
Ollama exposes an OpenAI-compatible API at `/v1/chat/completions`. That lets you switch providers without rewriting the agent.
# src/aulafy_r/llm.py
import httpx
from .config import Config, validate_local_llm
def chat(config: Config, messages: list[dict[str, str]]) -> str:
validate_local_llm(config)
url = config.llm.base_url.rstrip("/") + "/chat/completions"
payload = {
"model": config.llm.model,
"messages": messages,
"temperature": 0.2,
}
response = httpx.post(url, json=payload, timeout=120)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]4. CLI with direct chat
R uses a Click group that treats unknown commands as messages. That way `r "explica esto"` works without typing `chat`. This minimal version replicates that pattern.
# src/aulafy_r/main.py
import click
from rich.console import Console
from rich.panel import Panel
from .config import Config
from .llm import chat as llm_chat
console = Console()
class DirectChatGroup(click.Group):
def resolve_command(self, ctx, args):
try:
return super().resolve_command(ctx, args)
except click.UsageError:
if not args or args[0].startswith("-"):
raise
return "chat", self.get_command(ctx, "chat"), args
@click.group(cls=DirectChatGroup, invoke_without_command=True)
@click.pass_context
def cli(ctx):
if ctx.invoked_subcommand is None:
console.print(ctx.get_help())
@cli.command()
@click.argument("message", nargs=-1, required=True)
def chat(message):
config = Config.load()
text = " ".join(message)
answer = llm_chat(config, [{"role": "user", "content": text}])
console.print(Panel(answer, title="Aulafy R Mini"))
@cli.command()
def doctor():
config = Config.load()
console.print({
"model": config.llm.model,
"base_url": config.llm.base_url,
"local_only": config.security.local_only,
})
if __name__ == "__main__":
cli()ar doctor ar chat "Dime una receta de RAG local" ar "Ahora resume este proyecto como si fuera para una pyme"
5. Small skills, not superpowers
A skill is a tool with a contract. Don't give the model a `run_shell(command)` function. Give it `math.calculate`, `fs.read_text`, or `git.status`, each with clear limits.
# src/aulafy_r/skills/math_skill.py
import ast
import operator as op
OPS = {
ast.Add: op.add,
ast.Sub: op.sub,
ast.Mult: op.mul,
ast.Div: op.truediv,
ast.Pow: op.pow,
ast.USub: op.neg,
}
def calculate(expression: str) -> float:
def eval_node(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in OPS:
return OPS[type(node.op)](eval_node(node.left), eval_node(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in OPS:
return OPS[type(node.op)](eval_node(node.operand))
raise ValueError("Expresion no permitida")
tree = ast.parse(expression, mode="eval")
return eval_node(tree.body)# src/aulafy_r/skills/__init__.py
from .math_skill import calculate
TOOLS = {
"math.calculate": calculate,
}
def run_tool(name: str, **kwargs):
if name not in TOOLS:
raise KeyError(f"Tool no registrada: {name}")
return TOOLS[name](**kwargs)6. Permissions and auditing
The difference between a demo and a useful tool is here. Every call must be classified, authorized, and recorded.
# src/aulafy_r/permissions.py
from dataclasses import dataclass, asdict
from pathlib import Path
import json
import time
import uuid
RISK = {
"math": "low",
"fs": "medium",
"git": "high",
"docker": "critical",
"email": "critical",
}
@dataclass
class PermissionRequest:
tool: str
risk: str
arguments: dict
trace_id: str
def classify(tool: str) -> str:
skill = tool.split(".", 1)[0]
return RISK.get(skill, "high")
def authorize(tool: str, arguments: dict, auto_approve: bool = False) -> PermissionRequest:
request = PermissionRequest(
tool=tool,
risk=classify(tool),
arguments=arguments,
trace_id=str(uuid.uuid4()),
)
if request.risk in {"high", "critical"} and not auto_approve:
raise PermissionError(f"Permiso requerido para {tool} ({request.risk})")
audit(request, "allowed")
return request
def audit(request: PermissionRequest, outcome: str) -> None:
home = Path("~/.aulafy-r-mini").expanduser()
home.mkdir(parents=True, exist_ok=True)
record = {"ts": time.time(), "outcome": outcome, **asdict(request)}
with (home / "audit.jsonl").open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")7. Reproducible YAML workflows
R doesn't rely only on "asking the agent". It also supports declarative workflows: steps, dependencies, variables, dry-run, and retries. That's what turns a task into a repeatable capsule.
# workflow.yaml
version: 1
name: informe-calculo
steps:
- id: base
uses: math.calculate
with:
expression: "6 * 7"
- id: doble
uses: math.calculate
depends_on: [base]
with:
expression: "42 * 2"# src/aulafy_r/workflows.py
import yaml
from .permissions import authorize
from .skills import run_tool
def run_workflow(path: str, dry_run: bool = False):
raw = yaml.safe_load(open(path, encoding="utf-8"))
results = {}
for step in raw["steps"]:
for dep in step.get("depends_on", []):
if dep not in results:
raise RuntimeError(f"Dependencia pendiente: {dep}")
tool = step["uses"]
args = step.get("with", {})
if dry_run:
results[step["id"]] = {"dry_run": True, "tool": tool, "args": args}
continue
authorize(tool, args, auto_approve=True)
results[step["id"]] = run_tool(tool, **args)
return results8. Minimal Agent OS with SQLite
The queue is what separates a one-off command from an operational layer: you can create, pause, retry, and audit tasks.
# src/aulafy_r/agent_os.py
from pathlib import Path
import sqlite3
import time
import uuid
DB = Path("~/.aulafy-r-mini/agent-os.db").expanduser()
def connect():
DB.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB)
con.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
agent TEXT NOT NULL,
input TEXT NOT NULL,
status TEXT NOT NULL,
created_at REAL NOT NULL
)
""")
return con
def submit(agent: str, text: str) -> str:
task_id = str(uuid.uuid4())
with connect() as con:
con.execute(
"INSERT INTO tasks VALUES (?, ?, ?, ?, ?)",
(task_id, agent, text, "queued", time.time()),
)
return task_id
def list_tasks():
with connect() as con:
return con.execute(
"SELECT id, agent, status, input FROM tasks ORDER BY created_at DESC"
).fetchall()9. Agent manifest
An agent shouldn't be "the model with everything unlocked". It should have identity, instructions, skills, paths, and a network policy.
# researcher.yaml name: private-researcher description: Analiza documentos dentro de un proyecto concreto system_prompt: | Eres un investigador local. Cita evidencia y no inventes datos. skills: [fs, math] network_access: false filesystem_roots: - ./documents
What to add next
- Streaming: show tokens as they arrive.
- Real tool calling: pass schemas to the model and execute validated tools.
- Local API: FastAPI on `127.0.0.1` for a Control Center-style UI.
- Memory: SQLite for short sessions and Qdrant/GBrain for semantic memory.
- MCP: load servers manually, with an allowlist and no auto-loading.
- Distribution: `pyproject.toml`, tests, GitHub Actions, and releases.
Frequently asked questions
What is a local AI agent CLI like R
It's a terminal tool that runs AI agents on your own computer, using local models, limited skills, permissions, auditing, workflows, and persistent memory.
Do I need to use the raym33/r repo to follow the tutorial
No. The lesson shows how to try raym33/r as a reference and then build a minimal educational version from scratch.
What technologies does the minimal version use
Python, Click, Rich, Ollama, YAML, SQLite, custom skills, permission control, and reproducible workflows.
Sources and base project
- raym33/r on GitHub
- Click: official documentation
- Rich: official documentation
- Ollama API
- Python packaging: pyproject.toml