- Posted on
- • Artificial Intelligence
Artificial Intelligence Agent Tool Calling
- Author
-
-
- User
- linuxbash
- Posts by this author
- Posts by this author
-
Teaching Your Bash Scripts to Talk: AI Agent Tool Calling on Linux
Ever caught yourself typing a long chain of commands and flags and thought, “Why can’t I just tell my machine what I want?” Imagine saying: “Find the biggest logs under /var, summarize them, and tell me which service is spamming.” That’s the promise of AI agent tool calling: a safe way to let an AI pick which Bash functions to run and with what arguments—without handing it a raw shell.
This article explains why agent tool calling matters, how it avoids the pitfalls of “LLM writes shell” approaches, and walks you through a practical, minimal Bash implementation that connects a local toolkit to an LLM via an API. You’ll finish with a working agent that can inspect disk usage, search files, read logs, and check services—using only allow‑listed commands you define.
Why tool calling beats “LLM executes shell”
Safety through allowlists: Instead of letting an AI emit arbitrary commands, you expose only a curated set of tools (Bash functions) with typed parameters. The model can request a tool by name and pass structured JSON arguments, but can’t run anything you didn’t approve.
Reliability via structure: Tool calling asks the LLM for structured intent (name + arguments), which is easier to parse and test than freeform text or brittle prompt-to-bash generation.
Observability and auditability: Every tool call and argument is loggable JSON with clear provenance, so you can review the exact decisions the agent made.
Composability: You can steadily add tools (e.g., “tail_log”, “service_status”) without changing how the agent thinks or how you call it.
What we’ll build
A single Bash script that:
Accepts a natural-language request (e.g., “Which directories under /var use the most space?”).
Calls an LLM with an allow-listed set of “tools” described as JSON.
Parses the model’s tool call(s), runs the associated Bash functions locally (e.g.,
du,grep,systemctl), returns results to the model, and prints a final answer.
We’ll use:
curlfor HTTPjqfor JSONThe OpenAI Chat Completions API with tool-calling (you can swap to any OpenAI-compatible endpoint)
Prerequisites
Install these packages if you don’t already have them.
- Debian/Ubuntu (apt):
sudo apt update
sudo apt install -y curl jq
- Fedora/RHEL/CentOS Stream (dnf):
sudo dnf install -y curl jq
- openSUSE (zypper):
sudo zypper refresh
sudo zypper install -y curl jq
Set your API key:
export OPENAI_API_KEY="sk-...your-key..."
Tip: add that line (or use a secret manager) in your shell profile if you run the agent often.
The agent script (Bash + tool calling)
Save as agent.sh and make it executable with chmod +x agent.sh.
#!/usr/bin/env bash
set -euo pipefail
# Minimal Bash agent demonstrating "tool calling".
# Requirements: curl, jq, an OpenAI-compatible API key in $OPENAI_API_KEY
API_BASE="${API_BASE:-https://api.openai.com/v1}"
MODEL="${MODEL:-gpt-4o-mini}" # choose a tool-calling capable model
TEMPERATURE="${TEMPERATURE:-0.2}"
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo "ERROR: OPENAI_API_KEY is not set." >&2
exit 1
fi
USER_PROMPT="${1:-}"
if [[ -z "$USER_PROMPT" ]]; then
echo "Usage: $0 \"Your request in natural language\"" >&2
exit 1
fi
# ---------- Safety helpers ----------
abs_path_or_die() {
local p="$1"
# Basic hardening: require absolute path, forbid parent traversal
if [[ "$p" != /* ]] || [[ "$p" == *".."* ]]; then
echo "Invalid path: $p (must be absolute, no ..)" >&2
exit 2
fi
echo "$p"
}
# ---------- Local tools (allow-list) ----------
tool_get_disk_usage() {
local path="$1"
path="$(abs_path_or_die "$path")"
# Top directories by size (one level deep)
local top
top="$(du -x -d1 -h --apparent-size "$path" 2>/dev/null | sort -h | tail -n 15)"
# Filesystem free/used
local dfout
dfout="$(df -h "$path" 2>/dev/null | awk 'NR==1 || NR==2 {print}')"
printf "%s\n\n%s\n" "Top entries under $path (largest last):" "$top"
printf "\nFilesystem usage for %s:\n%s\n" "$path" "$dfout"
}
tool_search_files() {
local path="$1" pattern="$2" max_results="$3"
path="$(abs_path_or_die "$path")"
max_results="${max_results:-10}"
# Basic content search; tune excludes as needed
LC_ALL=C grep -RIn --binary-files=without-match \
--exclude-dir={.git,node_modules,venv,__pycache__} \
-m "$max_results" -e "$pattern" "$path" 2>/dev/null | head -n "$max_results" || true
}
tool_tail_log() {
local path="$1" lines="${2:-80}"
path="$(abs_path_or_die "$path")"
tail -n "$lines" "$path" 2>/dev/null || echo "Cannot read $path (permissions or missing file)."
}
tool_service_status() {
local service="$1"
local status logs
status="$(systemctl is-active "$service" 2>/dev/null || true)"
logs="$(journalctl -u "$service" -n 30 --no-pager 2>/dev/null || echo "No logs found or insufficient permissions.")"
printf "Service: %s\nStatus: %s\n\nRecent logs:\n%s\n" "$service" "$status" "$logs"
}
# ---------- Tool schema for the model ----------
read -r -d '' TOOLS_JSON <<'JSON' || true
[
{
"type": "function",
"function": {
"name": "get_disk_usage",
"description": "Show disk usage under a path and the largest subdirectories (one level).",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Absolute path to inspect, e.g., /var" }
},
"required": ["path"],
"additionalProperties": false
}
}
},
{
"type": "function",
"function": {
"name": "search_files",
"description": "Search recursively for a text pattern and return up to N matches with line numbers.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Absolute root path to search" },
"pattern": { "type": "string", "description": "Text or regex to search for" },
"max_results": { "type": "integer", "description": "Maximum number of matches to return", "default": 10 }
},
"required": ["path", "pattern"],
"additionalProperties": false
}
}
},
{
"type": "function",
"function": {
"name": "tail_log",
"description": "Tail the last N lines from a log file path.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Absolute path to the log file" },
"lines": { "type": "integer", "description": "Number of lines to show", "default": 80 }
},
"required": ["path"],
"additionalProperties": false
}
}
},
{
"type": "function",
"function": {
"name": "service_status",
"description": "Check a systemd service status and show recent logs.",
"parameters": {
"type": "object",
"properties": {
"service": { "type": "string", "description": "Service name, e.g., nginx" }
},
"required": ["service"],
"additionalProperties": false
}
}
}
]
JSON
SYSTEM_PROMPT="You are a cautious Linux SRE assistant. Use the provided tools when the user asks about files, disk usage, logs, or services. Prefer safe, read-only inspection. Never invent paths. If a path is missing or unsafe, ask for clarification."
MESSAGES_JSON="$(jq -n --arg s "$SYSTEM_PROMPT" --arg u "$USER_PROMPT" '[{role:"system",content:$s},{role:"user",content:$u}]')"
# ---------- API call helper ----------
call_api() {
local messages_json="$1"
local include_tools="${2:-1}"
if [[ "$include_tools" == "1" ]]; then
jq -n \
--arg model "$MODEL" \
--argjson messages "$messages_json" \
--argjson tools "$TOOLS_JSON" \
--argjson temp "$TEMPERATURE" \
'{model:$model, messages:$messages, tools:$tools, temperature:0.2}' |
curl -sS -X POST "$API_BASE/chat/completions" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d @-
else
jq -n \
--arg model "$MODEL" \
--argjson messages "$messages_json" \
--argjson temp "$TEMPERATURE" \
'{model:$model, messages:$messages, temperature:0.2}' |
curl -sS -X POST "$API_BASE/chat/completions" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d @-
fi
}
# ---------- First turn: ask the model ----------
FIRST_RESPONSE="$(call_api "$MESSAGES_JSON" 1)"
# If the model replies directly, print it and exit
DIRECT_CONTENT="$(jq -r '.choices[0].message.content // empty' <<<"$FIRST_RESPONSE")"
TOOL_CALLS_PRESENT="$(jq -e '.choices[0].message.tool_calls // empty' <<<"$FIRST_RESPONSE" >/dev/null && echo yes || echo no)"
if [[ "$TOOL_CALLS_PRESENT" == "no" && -n "$DIRECT_CONTENT" ]]; then
printf "%s\n" "$DIRECT_CONTENT"
exit 0
fi
# Otherwise, gather tool calls
ASSISTANT_MESSAGE_JSON="$(jq '.choices[0].message' <<<"$FIRST_RESPONSE")"
MESSAGES_JSON="$(jq --argjson msgs "$MESSAGES_JSON" --argjson a "$ASSISTANT_MESSAGE_JSON" '$msgs + [$a]')"
# Execute each requested tool
mapfile -t CALLS < <(jq -c '.choices[0].message.tool_calls[]' <<<"$FIRST_RESPONSE")
for call in "${CALLS[@]}"; do
call_id="$(jq -r '.id' <<<"$call")"
func_name="$(jq -r '.function.name' <<<"$call")"
# "arguments" is a JSON string; parse it safely with jq
args_json="$(jq -r '.function.arguments' <<<"$call")"
get_arg() { jq -r --arg k "$1" '.[$k] // empty' <<<"$args_json"; }
tool_output=""
case "$func_name" in
get_disk_usage)
path="$(get_arg path)"
tool_output="$(tool_get_disk_usage "$path" 2>&1 || true)"
;;
search_files)
path="$(get_arg path)"
pattern="$(get_arg pattern)"
max_results="$(get_arg max_results)"
tool_output="$(tool_search_files "$path" "$pattern" "$max_results" 2>&1 || true)"
;;
tail_log)
path="$(get_arg path)"
lines="$(get_arg lines)"
tool_output="$(tool_tail_log "$path" "$lines" 2>&1 || true)"
;;
service_status)
service="$(get_arg service)"
tool_output="$(tool_service_status "$service" 2>&1 || true)"
;;
*)
tool_output="Tool $func_name is not implemented."
;;
esac
# Append the tool result to messages
TOOL_MESSAGE="$(jq -n --arg id "$call_id" --arg content "$tool_output" '{role:"tool", tool_call_id:$id, content:$content}')"
MESSAGES_JSON="$(jq --argjson msgs "$MESSAGES_JSON" --argjson t "$TOOL_MESSAGE" '$msgs + [$t]')"
done
# Second turn: send tool results back to the model to get a final answer
FINAL_RESPONSE="$(call_api "$MESSAGES_JSON" 0)"
FINAL_TEXT="$(jq -r '.choices[0].message.content // "No response"' <<<"$FINAL_RESPONSE")"
printf "%s\n" "$FINAL_TEXT"
Notes
The script is intentionally conservative. It rejects relative paths and “..” to reduce risk.
All actions are read-only (analysis, search, tail, status). Add write/destructive tools only with confirmation gates.
You can swap
MODELorAPI_BASEvia env vars (e.g., OpenAI-compatible gateways).
Try these real-world prompts
- Disk triage:
./agent.sh "Under /var, which subdirectories are using the most space? Summarize the top offenders and suggest cleanup ideas."
- Log search:
./agent.sh "Search /var/log for 'error 500' and show 5 representative hits with filenames."
- Service health:
./agent.sh "Is nginx running? If it's not active, summarize the last 30 log lines."
- Investigate a noisy app:
./agent.sh "Tail the last 100 lines of /var/log/syslog and tell me if a single service is spamming."
4 tips to productionize your agent
1) Tighten the sandbox
Run as a dedicated low-privilege user.
Use timeouts and ulimits on tools:
timeout 10s ..., limit grep depth, etc.Consider wrappers like
firejailor containers for isolation.
2) Confirm before danger
- Keep your toolkit read-only at first. If you add “cleanup” or “restart” tools, require explicit confirmation from the user or use a dry-run mode.
3) Log everything
Save the full request/response JSON for audits (redact secrets).
Emit trace IDs in both your Bash logs and your LLM messages.
4) Expand your toolkit gradually
Add tools like
list_large_files,top_cpu_processes, orcheck_ports.Each new tool is just a function + a JSON schema entry.
What you gained
A practical understanding of AI agent tool calling: safe, structured, allow-listed command execution driven by natural language.
A working Bash agent you can extend with your own tools.
A pathway to bring AI superpowers to your terminal without giving it the keys to your system.
Call to action
Clone this pattern into your dotfiles or an internal repo. Add the 1–2 tools that would save you the most time this week.
Share your best prompt-tool combos with your team, and set a policy for adding new tools safely.
If you want a deeper dive (multi-turn memory, retries, streaming), build a tiny service around this script or port it to Python/Go while keeping the same tool API.
Happy automating—safely.