PolarSPARC

Agentic CLI From Scratch - Part 1


Bhaskar S 08/09/2026


Overview

Tools like Claude Code, Codex, or Gemini CLI are often treated like some kind of a magical tool. There are NOT. At the core is something we have probably seen before: a Reason & Act (or ReACT) agent loop.

We give it a prompt, it reasons about what to do, calls a tool, observes the result, reasons again, calls another tool, and repeats until the task is done. The "tools" are things we already know: cat, curl, grep, bash, etc.

What makes these magical CLI useful is how well some of the functional pieces come together such as:

This is not an exhaustive list of capabilities, but to get the point across.

In this 2-part series, we will demonstrate how one can build a local-only agentic CLI from scratch in Python. We will call our agentic CLI bcode !!!


Installation and Setup

The installation and setup will can on a Ubuntu 24.04 LTS based Linux desktop. Ensure that Docker is installed and setup on the desktop (see INSTRUCTIONS). Also, ensure the Python 3.1x programming language is installed and setup on the desktop.

For the hands-on demonstration, we will be using llama.cpp for serving the Qwen 3.6 35B LLM model.

We will create the required models directory by executing the following command in a terminal window:


$ mkdir -p $HOME/.llama_cpp/models


From the llama.cpp docker RESPOSITORY, one can identify the current version of the docker image. At the time of this article, the latest version of the docker image ended with the version b10331.

We require the docker image with the tag word full. If the desktop has an Nvidia GPU, one can look for the docker image with the tag words full-cuda.

To pull and download the full docker image for llama.cpp with CUDA support, execute the following command in a terminal window:


$ docker pull ghcr.io/ggml-org/llama.cpp:full-cuda-b10331


The following should be the typical output:


Output.1

full-cuda-b10331: Pulling from ggml-org/llama.cpp
5a7813e071bf: Pull complete 
a102f36d092c: Pull complete 
05ec76e31584: Pull complete 
398182656c47: Pull complete 
73389fbd088f: Pull complete 
cbb9175a9bc5: Pull complete 
3d6ab8c799cd: Pull complete 
7209097bfb98: Pull complete 
545a3ada5b6b: Pull complete 
349f727c9bfb: Pull complete 
c6870b6d216c: Pull complete 
fd21419d784e: Pull complete 
4f4fb700ef54: Pull complete 
5879e9b6cb49: Pull complete 
Digest: sha256:175523d32778c63efc3438e130da56693366a9902801785eb159b2b625e7bd89
Status: Downloaded newer image for ghcr.io/ggml-org/llama.cpp:full-cuda-b10331
ghcr.io/ggml-org/llama.cpp:full-cuda-b10331

Now, we will download the Qwen 3.6 LLM model from Huggingface - the bartowski/Qwen_Qwen3.6-35B-A3B-GGUF model.

Download Qwen 3.6 35B A3B (4-bit) model to the directory $HOME/.llama_cpp/models.

Next, to start the llama.cpp server for serving the Qwen 3.6 35B A3B (4-bit) model, execute the following command in the terminal window:


$ docker run --rm --name llama_cpp --gpus all --network host -v $HOME/.llama_cpp/models:/models ghcr.io/ggml-org/llama.cpp:full-cuda-b10331 --server --model /models/Qwen_Qwen3.6-35B-A3B-Q4_K_M.gguf --alias qwen3.6-a3b --host 192.168.1.25 --port 8000 --device CUDA0 --temp 1.0 --top_k 64 --top_p 0.95 --no-mmap --threads 4 --ctx-size 65536 --flash-attn on -ctk q4_0 -ctv q4_0


The following should be the typical output:


Output.2

0.00.163.698 I cmn  common_param: common_params_print_info: verbosity = 3 (adjust with the `-lv N` CLI arg)
0.00.258.629 W srv  llama_server: -----------------
0.00.258.632 W srv  llama_server: CORS is set to allow all origins ('*') and no API key is set
0.00.258.632 W srv  llama_server: this can be a security risk (cross-origin attacks)
0.00.258.632 W srv  llama_server: more info: https://github.com/ggml-org/llama.cpp/pull/25655
0.00.258.632 W srv  llama_server: -----------------
0.00.259.885 I srv    load_model: loading model '/models/Qwen_Qwen3.6-35B-A3B-Q4_K_M.gguf'
0.13.047.558 I srv    load_model: initializing, n_slots = 4, n_ctx_slot = 65536, kv_unified = 'true'
0.13.050.472 I srv          init: chat template supports preserving reasoning, consider enabling it via --reasoning-preserve
0.13.050.506 I srv  llama_server: model loaded
0.13.050.511 I srv  llama_server: listening on http://192.168.1.25:8000

Finally, to install the necessary Python packages, execute the following command:


$ pip install duckduckgo-search langchain langchain-core langchain-ollama langchain-openai pydantic rich tiktoken yaml


This completes all the system installation and setup for the agentic CLI development from scratch.


Agentic CLI from Scratch


The first version of the bcode agentic CLI will be basic supporting just the following 3 slash commands:

The LLM configuration parameters such as the provider, the model to use, the temperature settings, etc., will come from a YAML configuration file called settings.yaml.

The following would be the contents of the settings.yaml with the following environment variables defined:


#
# @Author: Bhaskar S
# @Blog:   https://polarsparc.github.io
# @Date:   07 Aug 2026
# @Ver:    1.0
#

agent:
  provider: llama.cpp
  model: qwen3.6-a3b
  temperature: 0.9
  top_p: 0.95
  top_k: 64
  base_url: http://192.168.1.25:8000

To load the configuration parameters from the settings.yaml, we will need a Python class and the necessary logic for loading the configuration.

The following is the Python script config.py which provides the desired functionality:


#
# @Author: Bhaskar S
# @Blog:   https://polarsparc.github.io
# @Date:   07 Aug 2026
# @Ver:    1.0
#

import logging
import os
import yaml
from pydantic import BaseModel

logging.basicConfig(format='%(levelname)s %(asctime)s - %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)

# Default path to the settings file
DEFAULT_SETTINGS_YAML = './settings.yaml'

class LLMConfig(BaseModel):
  """Holds the LLM parameters used to build the chat model"""
  provider: str = 'ollama'
  model: str = 'qwen-3.6'
  temperature: float = 0.0
  top_p: float = 0.5
  top_k: int = 32
  base_url: str = 'http://localhost:11434'

def load_config(path: str = DEFAULT_SETTINGS_YAML) -> LLMConfig:
  """Load LLMConfig from a YAML file, falling back to defaults"""
  data = {}
  logger.info(f'Config settings: {path}')
  if os.path.exists(path):
    with open(path, 'r') as settings:
      data = yaml.safe_load(settings) or {}
    logger.info(f'Loaded settings: {data}')
  else:
    logger.info(f'No settings file {path} not found, using defaults')

  return LLMConfig(**data['agent'])

if __name__ == '__main__':
  config = load_config()
  print(config)

Next, we will need some Python code to initialize the local LLM provider (either llama.cpp or ollama) using the provided configuration.

The following is the Python script llm.py which provides the desired functionality:


#
# @Author: Bhaskar S
# @Blog:   https://polarsparc.github.io
# @Date:   07 Aug 2026
# @Ver:    1.0
#

import logging
from langchain_ollama import ChatOllama
from langchain_openai import ChatOpenAI
from config import LLMConfig

logging.basicConfig(format='%(levelname)s %(asctime)s - %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)

def build_chat_model(config: LLMConfig):
  """Return a chat model instance matching config.provider"""
  if config.provider == 'ollama':
    logger.info(f'Ollama server: base-url -> {config.base_url}, model -> {config.model}')
    return ChatOllama(
      model=config.model,
      base_url=config.base_url,
      temperature=config.temperature,
      top_p=config.top_p,
      top_k=config.top_k,
    )

  if config.provider == 'llama.cpp':
    logger.info(f'Llama.cpp server: base-url -> {config.base_url}, model -> {config.model}')
    # llama.cpp's server exposes an OpenAI-compatible HTTP API, so we use ChatOpenAI
    return ChatOpenAI(
      model=config.model,
      base_url=config.base_url,
      api_key='llama.cpp',
      temperature=config.temperature,
      top_p=config.top_p,
      extra_body={'top_k': config.top_k},
    )

  raise ValueError(f'Unsupported provider -> {config.provider}')

Finally, we will need the Python code for the agentic CLI, which leverages the above indicated functionality to implement the ReACT agentic loop.

The following is the Python script bcode.py which provides the agentic CLI functionality:


#
# @Author: Bhaskar S
# @Blog:   https://polarsparc.github.io
# @Date:   07 Aug 2026
# @Ver:    1.0
#

import logging
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage
from langchain.agents import create_agent
from rich.console import Console
from rich.prompt import Prompt
from config import load_config
from llm import build_chat_model

DEFAULT_SYSTEM_MESSAGE = SystemMessage(content='You are a helpful AI task assistant')

logging.basicConfig(format='%(levelname)s %(asctime)s - %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)

console = Console()

def initial_history() -> list:
    """Return a clean conversation history containing only the system message"""
    return [DEFAULT_SYSTEM_MESSAGE]

def handle_prompt(agent, history: list, message: str):
    """Send the prompt message to the agent and return (response, new_history) the response as tokens arrive"""
    history = history + [HumanMessage(content=message)]

    response = ''
    for chunk, _ in agent.stream({'messages': history}, stream_mode='messages'):
        if isinstance(chunk, AIMessageChunk) and chunk.content:
            console.print(chunk.content, end='')
            response += chunk.content
    console.print()

    new_history = history + [AIMessage(content=response)]
    return response, new_history

def process_line(line: str, agent, history: list):
    """Dispatch the user prompt and return (output_message, new_history, should_continue) tuple.
    If /exit, exit the program"""
    line = line.strip()

    if line == '/exit':
        logger.info("Exiting bcode!")
        return None, history, False

    if line == '/clear':
        logger.info('Context clear command')
        return 'Context Cleared', initial_history(), True

    if line.startswith('/prompt'):
        message = line[len('/prompt'):].strip()
        if not message:
            return 'Usage: /prompt <message>', history, True
        try:
            response, new_history = handle_prompt(agent, history, message)
            return response, new_history, True
        except Exception as exc:
            logger.error(f'Error processing prompt: {exc}')
            return f'Error: {exc}', history, True

    return 'Unknown Command', history, True

def run() -> None:
    """Start the agent ReACT loop"""
    logger.info('starting bcode agent CLI')

    config = load_config()
    llm = build_chat_model(config)
    # No tools are registered in this version; the agent simply reasons and responds
    agent = create_agent(llm, tools=[])
    history = initial_history()

    console.print('[bold magenta3]-----> bcode v1.0 <-----[/bold magenta3]')
    while True:
        try:
            line = Prompt.ask('[bold dark_orange]bc[/bold dark_orange]', console=console, show_default=False)
        except (EOFError, KeyboardInterrupt):
            console.print()
            break

        if not line.strip():
            continue

        output, history, should_continue = process_line(line, agent, history)
        if output is not None:
            console.print(output)
        if not should_continue:
            break

    logger.info('bcode agent stopped')

if __name__ == "__main__":
    run()

To test our agentic CLI bcode, execute the following command in the terminal window:


$ python bcode.py


Once the agentic CLI is ready, the user is presented with the bc: command prompt.

Execute the following /prompt command:


bc: /prompt how much memory does this system have


The following should be the typical trimmed output:


Output.3

INFO 2026-08-08 17:55:26,198 - HTTP Request: POST http://192.168.1.25:8000/chat/completions "HTTP/1.1 200 OK"
I don't have direct access to your hardware or operating system, so I can't check your memory (RAM) myself. However, you can easily find it out:

** Windows:**
- Press `Ctrl + Shift + Esc` to open Task Manager
- Click the **Performance** tab -> select **Memory**
- The top-right corner will show your total RAM (e.g., `16.0 GB`)

** macOS:**
- Click the Apple menu -> **About This Mac**
- Under the **Overview** tab, it will list **Memory** (e.g., `16 GB`)

** Linux:**
- Open a terminal and run: `free -h`
- Or: `cat /proc/meminfo | grep MemTotal`

Reply with your OS or paste the output, and I'll help you read it or advise whether it's sufficient for your use case!

Executing the following /exit command will exit the agentic CLI bcode !

From the Output.3 above, it was clear that the agentic CLI had no access to tools and hence was not able to accomplish the desired task.

Let us iterate to the next version of bcode with support for tools !

The second version of the bcode agentic CLI will support the following tools:

In addition, each tool can have one of the following permissions:

The tool permissions will be set in the YAML configuration file called settings.yaml.

The following would be the contents of the settings.yaml for the second version:


#
# @Author: Bhaskar S
# @Blog:   https://polarsparc.github.io
# @Date:   07 Aug 2026
# @Ver:    1.1
#

agent:
  provider: llama.cpp
  model: qwen3.6-a3b
  temperature: 0.9
  top_p: 0.95
  top_k: 64
  base_url: http://192.168.1.25:8000
  tools: True

tools:
  bash: ask
  edit: allow
  grep: allow
  read: allow
  websearch: ask
  write: allow

The following will be the second version of the Python script config.py which loads all the configuration settings:


#
# @Author: Bhaskar S
# @Blog:   https://polarsparc.github.io
# @Date:   07 Aug 2026
# @Ver:    1.1
#

import logging
import os
import yaml
from pydantic import BaseModel

logging.basicConfig(format='%(levelname)s %(asctime)s - %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)

# Default path to the settings file
DEFAULT_SETTINGS_YAML = './settings.yaml'

class LLMConfig(BaseModel):
  """Holds the LLM parameters used to build the chat model"""
  provider: str = 'ollama'
  model: str = 'qwen-3.6'
  temperature: float = 0.0
  top_p: float = 0.5
  top_k: int = 32
  base_url: str = 'http://localhost:11434'
  tools: bool = False

class LLMTools(BaseModel):
  """Holds the LLM tools along with their permissions used to build the chat model"""
  tool_perm: dict[str, str] = {}

def load_config(path: str = DEFAULT_SETTINGS_YAML) -> LLMConfig:
  """Load LLMConfig from a YAML file, falling back to defaults"""
  params = {}
  logger.info(f'Config settings: {path}')
  if os.path.exists(path):
    with open(path, 'r') as settings:
      params = yaml.safe_load(settings) or {}
    logger.info(f'Loaded settings: {params}')
  else:
    logger.info(f'No settings file {path} not found, using defaults')

  return LLMConfig(**params['agent']), LLMTools(tool_perm=params['tools'])

if __name__ == '__main__':
  config, tool_permissions = load_config()
  logger.info(f'config: {config}, tools: {tool_permissions}')

We will need some Python code to setup all the above mentioned tools. This is where the following Python script tools.py comes into the picture:


#
# @Author: Bhaskar S
# @Blog:   https://polarsparc.github.io
# @Date:   08 Aug 2026
# @Ver:    1.1
#

import logging
import os
import re
import subprocess
from ddgs import DDGS
from langchain_core.tools import tool
from rich.prompt import Confirm
from config import LLMTools

logging.basicConfig(format='%(levelname)s %(asctime)s - %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)

# Any operation targeting /etc or anything under it must be blocked
_ETC_PATH = os.path.realpath('/etc')

def _guard_etc_path(path: str) -> None:
  """Raise PermissionError if path resolves to /etc or anything under it"""
  resolved = os.path.realpath(os.path.abspath(os.path.expanduser(path)))
  if resolved == _ETC_PATH or resolved.startswith(_ETC_PATH + os.sep):
    raise PermissionError(f'Access to system files under /etc is not permitted: {path}')

def _guard_etc_in_command(command: str) -> None:
  """Best-effort textual block of /etc references in a shell command"""
  if '/etc' in command:
    raise PermissionError('Shell commands referencing /etc are not permitted')

@tool
def bash(command: str) -> str:
  """Execute a shell command or executable and return its output"""
  try:
    _guard_etc_in_command(command)
    result = subprocess.run(
      command, shell=True, capture_output=True, text=True, timeout=30
    )
    output = result.stdout + result.stderr
    return f'(Exit code from bash {result.returncode})\n{output}'
  except subprocess.TimeoutExpired:
    return 'Bash Error: command timed out after 30 seconds'
  except Exception as exc:
    return f'Bash Error: {exc}'

@tool
def edit(path: str, old_string: str, new_string: str) -> str:
  """Replace the first occurrence of old_string with new_string in a file"""
  try:
    _guard_etc_path(path)
    with open(path, 'r') as f:
      content = f.read()
    if old_string not in content:
      return f'Edit Error: old_string not found in {path}'
    content = content.replace(old_string, new_string, 1)
    with open(path, 'w') as f:
      f.write(content)
    return f'Edited {path}'
  except Exception as exc:
    return f'Edit Error: {exc}'

@tool
def grep(pattern: str, path: str = '.') -> str:
  """Search for lines matching a regex pattern in a file or directory"""
  try:
    _guard_etc_path(path)

    if os.path.isfile(path):
      file_paths = [path]
    else:
      file_paths = []
      for root, _, filenames in os.walk(path):
        for filename in filenames:
          file_paths.append(os.path.join(root, filename))

    regex = re.compile(pattern)

    matches = []
    for file_path in file_paths:
      try:
        _guard_etc_path(file_path)
        with open(file_path, 'r', errors='ignore') as f:
          for line_number, line in enumerate(f, start=1):
            if regex.search(line):
              matches.append(f'{file_path}:{line_number}: {line.rstrip()}')
      except (PermissionError, OSError):
        continue

    if not matches:
      return "No matches found."

    max_matches = 100
    if len(matches) > max_matches:
      matches = matches[:max_matches] + [f'... [truncated, {len(matches)} total matches]']
    return '\n'.join(matches)
  except Exception as exc:
    return f'Grep Error: {exc}'

@tool
def read(path: str) -> str:
  """Read and return the contents of a file"""
  try:
    _guard_etc_path(path)
    with open(path, 'r') as f:
      content = f.read()
    max_chars = 1024 * 16
    if len(content) > max_chars:
      return content[:max_chars] + f'\n... [truncated, {len(content)} total characters]'
    return content
  except Exception as exc:
    return f'Read Error: {exc}'

@tool
def websearch(query: str) -> str:
  """Search the web and return a short list of matching results"""
  try:
    results = DDGS().text(query, max_results=5)
    if not results:
      return 'No web search results found'
    lines = []
    for r in results:
      lines.append(f"{r.get('title', '')}\n{r.get('href', '')}\n{r.get('body', '')}")
    return '\n\n'.join(lines)
  except Exception as exc:
    return f'Websearch Error: {exc}'

@tool
def write(path: str, content: str) -> str:
  """Write content to a file, overwriting it if it already exists"""
  try:
    _guard_etc_path(path)
    with open(path, 'w') as f:
      f.write(content)
    return f'Wrote {len(content)} characters to {path}'
  except Exception as exc:
    return f'Write Error: {exc}'

TOOL_REGISTRY = {
  "bash": bash,
  "edit": edit,
  "grep": grep,
  "read": read,
  "websearch": websearch,
  "write": write,
}

def _is_tool_allowed(name: str, permissions: LLMTools) -> bool:
  """Decide whether tool `name` is allowed"""
  perm = permissions.tool_perm.get(name)
  if perm == 'allow':
    return True
  if perm == 'ask':
    return Confirm.ask(f'Allow tool [bold bright_red]{name}[/bold bright_red]?', default=False)
  return False

def _wrap_with_permission_check(name, tool_ref, permissions: LLMTools):
  """Return a wrapper on a tool that checks _is_tool_allowed before each call"""
  logger.info(f"Wrapping tool function {tool_ref.func} for '{name}' ")

  original_func = tool_ref.func

  def guarded_func(**kwargs):
    if not _is_tool_allowed(name, permissions):
      logger.info(f"Tool '{name}' call blocked")
      raise PermissionError(f"Tool '{name}' call blocked")
    return original_func(**kwargs)

  return tool_ref.model_copy(update={'func': guarded_func})

def get_tools(permissions: LLMTools):
  """Return all tools from the registry, each wrapped by _wrap_with_permission_check"""
  return [
    _wrap_with_permission_check(name, tool_ref, permissions) for name, tool_ref in TOOL_REGISTRY.items()
  ]

if __name__ == '__main__':
  logger.info(get_tools(LLMTools()))

Finally, we will need to update Python code for the agentic CLI to use the tools. The following is the second version for the Python script bcode.py:


#
# @Author: Bhaskar S
# @Blog:   https://polarsparc.github.io
# @Date:   08 Aug 2026
# @Ver:    1.1
#

import logging
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage
from langchain.agents import create_agent
from rich.console import Console
from rich.prompt import Prompt
from config import load_config
from llm import build_chat_model
from tools import get_tools

DEFAULT_SYSTEM_MESSAGE = SystemMessage(content='You are a helpful AI task assistant')

logging.basicConfig(format='%(levelname)s %(asctime)s - %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)

console = Console()

def initial_history() -> list:
  """Return a clean conversation history containing only the system message"""
  return [DEFAULT_SYSTEM_MESSAGE]

def handle_prompt(agent, history: list, message: str):
  """Send the prompt message to the agent and return (response, new_history) the response as tokens arrive"""
  history = history + [HumanMessage(content=message)]

  response = ''
  for chunk, _ in agent.stream({'messages': history}, stream_mode='messages'):
    if isinstance(chunk, AIMessageChunk) and chunk.content:
      console.print(chunk.content, end='')
      response += chunk.content
  console.print()

  new_history = history + [AIMessage(content=response)]
  return response, new_history

def process_line(line: str, agent, history: list):
  """Dispatch the user prompt and return (output_message, new_history, should_continue) tuple.
  If /exit, exit the program"""
  line = line.strip()

  if line == '/exit':
    logger.info("Exiting bcode!")
    return None, history, False

  if line == '/clear':
    logger.info('Context clear command')
    return 'Context Cleared', initial_history(), True

  if line.startswith('/prompt'):
    message = line[len('/prompt'):].strip()
    if not message:
      return 'Usage: /prompt <message>', history, True
    try:
      response, new_history = handle_prompt(agent, history, message)
      return response, new_history, True
    except Exception as exc:
      logger.error(f'Error processing prompt: {exc}')
      return f'Error: {exc}', history, True

  return 'Unknown Command', history, True

def run() -> None:
  """Start the agent ReACT loop"""
  logger.info('starting bcode agent CLI')

  config, permissions = load_config()
  llm = build_chat_model(config)

  if config.tools:
    tools = get_tools(permissions)
  else:
    tools = []

  agent = create_agent(llm, tools=tools)
  history = initial_history()

  console.print('[bold magenta3]-----> bcode v1.1 <-----[/bold magenta3]')
  while True:
    try:
      line = Prompt.ask('[bold dark_orange]bc[/bold dark_orange]', console=console, show_default=False)
    except (EOFError, KeyboardInterrupt):
      console.print()
      break

    if not line.strip():
      continue

    output, history, should_continue = process_line(line, agent, history)
    if output is not None:
      console.print(output)
    if not should_continue:
      break

  logger.info('bcode agent stopped')

if __name__ == "__main__":
  run()

To test our agentic CLI bcode, execute the following command in the terminal window:


$ python bcode.py


Once the agentic CLI is ready, the user is presented with the bc: command prompt.

Execute the following /prompt command:


bc: /prompt how much memory does this system have


The following should be the typical trimmed output:


Output.4

INFO 2026-08-08 18:02:00,975 - HTTP Request: POST http://192.168.1.25:8000/chat/completions "HTTP/1.1 200 OK"
Allow tool bash? [y/n] (n): y
INFO 2026-08-08 18:02:06,931 - HTTP Request: POST http://192.168.1.25:8000/chat/completions "HTTP/1.1 200 OK"
The system has **62 GiB** of total memory.

Here is the current status:
*   **Total Memory:** 62 GiB
*   **Used:** 15 GiB
*   **Available:** 47 GiB (including buffers and cache)
*   **Swap Space:** 14 GiB
The system has **62 GiB** of total memory.cat

Here is the current status:
*   **Total Memory:** 62 GiB
*   **Used:** 15 GiB
*   **Available:** 47 GiB (including buffers and cache)
*   **Swap Space:** 14 GiB

Executing the following /exit command will exit the agentic CLI bcode !

Notice that bcode asks the user for permission before executing the tool bash .

With this, we conclude Part 1 of the series on building an agentic CLI from scratch !!!



© PolarSPARC