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