Posted on
Artificial Intelligence

Build Your First Artificial Intelligence Agent with Bash

Author
  • User
    linuxbash
    Posts by this author
    Posts by this author

Build Your First Artificial Intelligence Agent with Bash

What if you could automate research, summarize logs, or file notes—without leaving your terminal or adding a heavy runtime? You can. With nothing more than Bash, curl, and jq, you’ll build a tiny but capable AI agent that can browse a URL, read/write files in a sandbox, and iteratively plan its next step until it finishes the job.

This article shows you how to:

  • Install the minimal tooling on any major Linux distro

  • Plug into either a cloud LLM or a local model

  • Write a small Bash agent that plans, calls tools, and produces a final answer

  • Try real tasks you can actually use

Why Bash for an AI Agent?

  • Ubiquity: Bash is on practically every server, container, and CI system.

  • Zero friction: Use curl to call LLMs over HTTP and jq to parse JSON.

  • Integrations: Combine LLM reasoning with your existing scripts and utilities.

  • Control: Keep the agent contained. No unbounded shell exec—only safe, explicit tools.

What You’ll Build

A single Bash script that:

  • Talks to an LLM (cloud or local)

  • Asks the model to respond with strict JSON

  • Provides a handful of safe “tools” (HTTP GET, save/read text in a sandbox, list files)

  • Iterates in a small loop until the model returns a final answer

You’ll then run it on a real request like: “Fetch the current weather and save it to a file.”


1) Install prerequisites

We’ll use curl for HTTP and jq for JSON parsing.

  • Debian/Ubuntu (apt):
sudo apt update
sudo apt install -y curl jq coreutils
  • Fedora/RHEL/CentOS (dnf):
sudo dnf install -y curl jq coreutils
  • openSUSE (zypper):
sudo zypper refresh
sudo zypper install -y curl jq coreutils

2) Choose your model backend

You can use either a cloud model (easiest path if you already have an API key) or a local model with Ollama.

Option A — Cloud (OpenAI-compatible):

  • Set your environment variables. Example for OpenAI:
export OPENAI_API_KEY="sk-...your key..."
export LLM_API_BASE="https://api.openai.com/v1"
export LLM_MODEL="gpt-4o-mini"

Notes:

  • You can use other OpenAI-compatible services by changing LLM_API_BASE and LLM_MODEL accordingly.

Option B — Local (Ollama):

  • Install Ollama (review the script before running):
curl -fsSL https://ollama.com/install.sh | sh
  • Start Ollama (if not already running) and pull a model:
ollama serve  # in a separate terminal, or run as a service
ollama pull llama3.1
export OLLAMA_MODEL="llama3.1"

The script below auto-detects: if OPENAI_API_KEY is set, it uses cloud; otherwise it tries Ollama on localhost.


3) Create the agent script

Save the following as agent.sh and make it executable:

chmod +x agent.sh

Content of agent.sh:

#!/usr/bin/env bash
set -Eeuo pipefail

# Minimal checks
for cmd in curl jq; do
  command -v "$cmd" >/dev/null || { echo "Missing dependency: $cmd"; exit 1; }
done

# Backend detection: OpenAI-compatible if OPENAI_API_KEY is set, otherwise Ollama if available
BACKEND=""
if [[ -n "${OPENAI_API_KEY:-}" ]]; then
  BACKEND="openai"
elif curl -fsS http://localhost:11434/api/version >/dev/null 2>&1; then
  BACKEND="ollama"
else
  echo "No LLM backend found.

- Set OPENAI_API_KEY (and optionally LLM_API_BASE, LLM_MODEL), or

- Install & run Ollama: https://ollama.com, then 'ollama pull llama3.1'"
  exit 1
fi

SANDBOX="${SANDBOX:-./sandbox}"
mkdir -p "$SANDBOX"

# Tools: safe helpers scoped to a sandbox
tool_http_get() {
  local url="${1:-}"
  if [[ -z "$url" || ! "$url" =~ ^https?:// ]]; then
    echo "error: only http(s) URLs allowed" ; return 1
  fi
  # Limit output size to keep context small
  curl -fsSL "$url" | head -c 4000
}

tool_save_text() {
  local path="${1:-}"
  local text="${2:-}"
  if [[ -z "$path" ]]; then echo "error: missing path"; return 1; fi
  case "$path" in
    *".."*|/*) echo "error: invalid path"; return 1;;
  esac
  mkdir -p "$SANDBOX"
  local target="$SANDBOX/$path"
  printf "%s" "$text" > "$target"
  echo "wrote $(wc -c < "$target") bytes to $target"
}

tool_read_text() {
  local path="${1:-}"
  if [[ -z "$path" ]]; then echo "error: missing path"; return 1; fi
  case "$path" in
    *".."*|/*) echo "error: invalid path"; return 1;;
  esac
  cat "$SANDBOX/$path" 2>&1 | head -c 2000
}

tool_list_files() {
  ls -1 "$SANDBOX" 2>/dev/null || echo "(empty)"
}

SYSTEM_PROMPT=$'You are a careful, tool-using assistant embedded in a Bash script.
Always respond ONLY with a single JSON object, no extra text.
Schema:
{
  "action": "http_get" | "save_text" | "read_text" | "list_files" | "final",
  "input": string | object,    // see tool docs
  "notes": string               // brief rationale (optional)
}'

# Tool documentation included in the prompt so the model knows how to call them.
TOOLS_DOC=$'Tools:

- http_get(url: string) -> string
  Fetches URL content (first ~4000 bytes). Only http(s) allowed.

- save_text({ "path": "relative/filename.txt", "text": "content" }) -> string
  Writes text to SANDBOX path; no absolute paths or .. allowed.

- read_text(path: string) -> string
  Reads a file from the sandbox; returns first ~2000 bytes.

- list_files("") -> string
  Lists files in the sandbox directory.
When you have the final answer for the user, return action:"final" and input:"<answer>".'

# LLM caller that requests JSON-formatted output
llm_json() {
  local prompt="$1"
  if [[ "$BACKEND" == "openai" ]]; then
    local base="${LLM_API_BASE:-https://api.openai.com/v1}"
    local model="${LLM_MODEL:-gpt-4o-mini}"
    local payload
    payload=$(jq -n \
      --arg model "$model" \
      --arg system "$SYSTEM_PROMPT" \
      --arg user "$prompt" \
      '{
         model: $model,
         temperature: 0.2,
         response_format: {type:"json_object"},
         messages: [
           {role:"system", content:$system},
           {role:"user", content:$user}
         ]
       }')
    curl -sS -H "Authorization: Bearer ${OPENAI_API_KEY}" \
         -H "Content-Type: application/json" \
         "$base/chat/completions" -d "$payload" \
      | jq -r '.choices[0].message.content'
  else
    local model="${OLLAMA_MODEL:-llama3.1}"
    local payload
    payload=$(jq -n \
      --arg model "$model" \
      --arg system "$SYSTEM_PROMPT" \
      --arg user "$prompt" \
      '{
         model: $model,
         stream: false,
         format: "json",
         options: { temperature: 0.2 },
         messages: [
           {role:"system", content:$system},
           {role:"user", content:$user}
         ]
       }')
    curl -sS http://localhost:11434/api/chat -d "$payload" \
      | jq -r '.message.content'
  fi
}

# Build the agent loop
GOAL="${*:-Fetch the current weather for Paris from https://wttr.in/Paris?format=3 and save it to weather.txt, then tell me the filename.}"
CONTEXT=""
MAX_STEPS="${MAX_STEPS:-8}"

echo "Goal: $GOAL"
for (( step=1; step<=MAX_STEPS; step++ )); do
  PROMPT=$'You are running inside a loop. Think step-by-step and choose one tool per turn.\n\n'\
"$TOOLS_DOC"$'\n\nContext so far:\n'\
"$CONTEXT"$'\n\nUser goal:\n'"$GOAL"$'\n\nImportant:\n- Only output a single JSON object as specified.\n- If you are done, use action:\"final\" with your final answer in input.'

  RESP_RAW="$(llm_json "$PROMPT" || true)"
  if [[ -z "${RESP_RAW:-}" ]]; then
    echo "Model returned no content; aborting." >&2
    exit 1
  fi

  # Validate JSON
  if ! echo "$RESP_RAW" | jq . >/dev/null 2>&1; then
    echo "Non-JSON response from model at step $step:"
    echo "$RESP_RAW"
    exit 1
  fi

  ACTION="$(echo "$RESP_RAW" | jq -r '.action // empty')"
  INPUT_TYPE="$(echo "$RESP_RAW" | jq -r 'if .input == null then "null" else ( .input | type ) end')"

  case "$ACTION" in
    final)
      FINAL_TEXT="$(echo "$RESP_RAW" | jq -r '.input // ""')"
      echo
      echo "Final answer:"
      echo "$FINAL_TEXT"
      exit 0
      ;;
    http_get)
      if [[ "$INPUT_TYPE" != "string" ]]; then
        OUT="error: http_get expects a string URL"
      else
        URL="$(echo "$RESP_RAW" | jq -r '.input')"
        OUT="$(tool_http_get "$URL" 2>&1 || true)"
      fi
      ;;
    save_text)
      if [[ "$INPUT_TYPE" != "object" ]]; then
        OUT="error: save_text expects an object {path,text}"
      else
        PATH_ARG="$(echo "$RESP_RAW" | jq -r '.input.path // empty')"
        TEXT_ARG="$(echo "$RESP_RAW" | jq -r '.input.text // empty')"
        OUT="$(tool_save_text "$PATH_ARG" "$TEXT_ARG" 2>&1 || true)"
      fi
      ;;
    read_text)
      if [[ "$INPUT_TYPE" != "string" ]]; then
        OUT="error: read_text expects a string path"
      else
        PATH_ARG="$(echo "$RESP_RAW" | jq -r '.input')"
        OUT="$(tool_read_text "$PATH_ARG" 2>&1 || true)"
      fi
      ;;
    list_files)
      OUT="$(tool_list_files 2>&1 || true)"
      ;;
    *)
      OUT="error: unknown action '$ACTION'"
      ;;
  esac

  # Record the observation (truncate to keep context bounded)
  SHORT_OUT="$(printf "%s" "$OUT" | head -c 800)"
  SHORT_ACTION_INPUT="$(echo "$RESP_RAW" | jq -c '{action, input}' | head -c 400)"
  CONTEXT+=$'\n'"[step $step] tool_call: $SHORT_ACTION_INPUT"
  CONTEXT+=$'\n'"[step $step] tool_result: $SHORT_OUT"$'\n'
  echo "[step $step] $ACTION -> done"
done

echo
echo "Stopped after $MAX_STEPS steps without a final answer."
exit 2

What this script does:

  • Forces JSON-only replies from the model.

  • Exposes just four safe tools.

  • Maintains a small running context for the agent loop.

  • Uses either OpenAI-compatible API or Ollama automatically.

Run it:

./agent.sh "Fetch https://wttr.in/Paris?format=3 and save it to weather.txt, then confirm the filename."

Example expected behavior:

  • Step 1: http_get the URL

  • Step 2: save_text to weather.txt

  • Step 3: final message summarizing where it wrote the file

You can inspect the result:

ls -l sandbox
cat sandbox/weather.txt

4) Real-world examples to try

  • Research snippet to note:
./agent.sh "Open https://www.kernel.org/ in http_get, extract the latest stable release version, save it to notes.txt, and finish."
  • Summarize a log:
echo -e "ERROR: Timeout on job 42\nINFO: Retrying\nERROR: Disk full" > sandbox/app.log
./agent.sh "Read sandbox/app.log and summarize the errors in 2 bullet points. Save as summary.txt and finish."
  • Quick scratchpad:
./agent.sh "Create a file called ideas.txt containing three CLI ideas for automating home server backups; then list files and finish."

Tip: If the agent gets stuck, re-run with a clearer goal or increase MAX_STEPS:

MAX_STEPS=12 ./agent.sh "..."

5) Design notes and safety

  • Keep the toolset explicit: No arbitrary shell execution. Add tools purposefully.

  • Sandboxed file access: All writes and reads happen under ./sandbox to avoid accidents.

  • Bound the context: Truncating tool output keeps token usage and responses stable.

  • Enforce JSON: The response_format (OpenAI) or format (Ollama) reduces parsing errors.

To extend the agent, add more tools with well-defined inputs, document them in TOOLS_DOC, and update the case statement.


Conclusion and Next Steps (CTA)

You just wired up a functioning AI agent using plain Bash. From here:

  • Add a “grep_log” tool to search logs safely.

  • Add a “markdown_to_html” tool that uses pandoc for local conversions.

  • Swap models (cloud or local) to balance speed, cost, and privacy.

  • Wrap this agent in a cron job or a CI step to automate daily tasks.

If you found this useful, try extending the toolset and share your favorite additions. Bash is still one of the quickest ways to prototype real, working AI-powered automation on Linux.