Aulafy
Courses/Agents and automation/Building an R-style agent CLI

Building an R-style agent CLI

In this lesson, you'll build a minimal version of R, Ramón Guillamón's open-source project: a local-first CLI for private agents with local models, skills, permissions, workflows, and persistent memory.

  • 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.
TerminalCode
# 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.

TerminalCode
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.py

1. Create the installable package

A serious CLI should install a real command, not depend on running `python script.py`.

TerminalCode
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.

TerminalCode
# 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.

TerminalCode
# 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.

TerminalCode
# 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()
TerminalCode
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.

TerminalCode
# 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)
TerminalCode
# 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.

TerminalCode
# 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.

TerminalCode
# 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"
TerminalCode
# 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 results

8. 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.

TerminalCode
# 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.

TerminalCode
# 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