Skip to content
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 "summarize this 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.
Terminal
# Try the real R as a reference
git clone https://github.com/aulafy/r.git
cd r
python -m venv .venv
source .venv/bin/activate
python -m pip install -e ".[dev]"

# Local model
ollama pull qwen2.5:7b
ollama serve

# Minimal configuration
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 "Summarize this repository in 5 points"

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.

Terminal
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`.

Terminal
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 = "Educational local-first CLI for private AI agents"
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.

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("Non-local LLM endpoint. Use 127.0.0.1 or 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.

Terminal
# 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 "explain this"` works without typing `chat`. This minimal version replicates that pattern.

Terminal
# 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()
Terminal
ar doctor
ar chat "Give me a local RAG recipe"
ar "Now summarize this project as if it were for an SMB"

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.

Terminal
# 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("Expression not allowed")

    tree = ast.parse(expression, mode="eval")
    return eval_node(tree.body)
Terminal
# 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 not registered: {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.

Terminal
# 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"Permission required for {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.

Terminal
# workflow.yaml
version: 1
name: calculation-report
steps:
  - id: base
    uses: math.calculate
    with:
      expression: "6 * 7"
  - id: doble
    uses: math.calculate
    depends_on: [base]
    with:
      expression: "42 * 2"
Terminal
# 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"Pending dependency: {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.

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

Terminal
# researcher.yaml
name: private-researcher
description: Analyzes documents within a specific project
system_prompt: |
  You are a local researcher. Cite evidence and do not invent data.
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 aulafy/r repo to follow the tutorial

No. The lesson shows how to try aulafy/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

  • aulafy/r on GitHub
  • Click: official documentation
  • Rich: official documentation
  • Ollama API
  • Python packaging: pyproject.toml
Complete Aulafy mapSee how this lesson fits without leaving your path.

Complete Aulafy map

How all courses connect

This is not a checklist. Start with the foundation, choose an outcome, and go deeper only when your project needs more control.

  1. 1Understand
  2. 2Apply or build
  3. 3Operate with confidence
01

Choose an application

Turn the foundation into a visible outcome: a website, a business improvement, media, or an interactive experience.

Continue into the technical branch when you need to maintain code, data, or infrastructure.

02

Build with code

Prepare your environment, work with coding agents, and run models while keeping control of your projects.

This branch prepares you to design and operate reliable AI systems.

03

Take systems to production

Combine retrieval, agents, evaluation, security, deployment, and model adaptation when the problem requires it.

You do not need every course: choose the component your system needs and return as it grows.

View full catalogue