Posted on
Artificial Intelligence

Artificial Intelligence Agent Best Practices

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

AI Agents from the Shell: Best Practices for Reliable, Safe, and Reproducible Automation

AI agents promise to turn messy, multi-step tasks into one command. But without guardrails, they can run up your bill, leak secrets, or simply fail in surprising ways. The good news: the Linux command line gives you everything you need to build agents that are safe, observable, and repeatable—no heavy frameworks required.

This post explains why best practices for AI agents matter and gives you a handful of practical Bash patterns you can paste into your own scripts today.


Why this matters (and why Bash is perfect for it)

  • Reliability beats novelty. Many “autonomous” agents look magical in demos but crumble under rate limits, flaky networks, or ambiguous outputs. Bash’s discipline—idempotency, composability, small tools—helps.

  • Safety is non‑negotiable. Least-privilege execution, clear allowlists, and sandboxes are standard practice on Linux. Your agents should follow suit.

  • Reproducibility wins. With simple caches, logs, and tests, you can rerun an agent tomorrow and get the same outcome, audit what happened, and improve with confidence.


Prerequisites: Install the basics

These examples use a few standard CLI tools. Install them with your distro’s package manager.

  • curl (HTTP client)

  • jq (JSON processor)

  • podman (container sandbox; rootless by default)

  • firejail (namespace/seccomp sandbox)

  • bats (Bash tests; optional but recommended)

Debian/Ubuntu (apt):

sudo apt update
sudo apt install -y curl jq podman firejail bats

Fedora/RHEL (dnf):

sudo dnf install -y curl jq podman firejail bats

openSUSE (zypper):

sudo zypper refresh
sudo zypper install -y curl jq podman firejail bats

Note: If any package isn’t available on your distro version, install the others and proceed; you can swap podman/firejail for the one you have.


1) Isolate by default: run agent tools with least privilege

Agents that can “call tools” should run in a sandbox with no network and minimal filesystem access. Two easy options:

  • Firejail (fast, no images to pull)

  • Podman (rootless containers with strong isolation)

Example: allowlisted commands executed in a sandboxed workspace.

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

WORKDIR="${WORKDIR:-$PWD/work}"
mkdir -p "$WORKDIR"

# Allowlist of safe commands for the agent to run
is_allowed_cmd() {
  case "$1" in
    grep|sed|awk|cut|sort|uniq|wc|head|tail|cat|tr) return 0 ;;
    *) return 1 ;;
  esac
}

safe_run_firejail() {
  local cmd="$1"; shift
  is_allowed_cmd "$cmd" || { echo "Denied: $cmd"; return 1; }
  firejail --quiet --net=none --private="$WORKDIR" -- bash -lc "$cmd \"\$@\"" _ "$@"
}

safe_run_podman() {
  local cmd="$1"; shift
  is_allowed_cmd "$cmd" || { echo "Denied: $cmd"; return 1; }
  podman run --rm --network=none --read-only \
    -v "$WORKDIR":/work:Z -w /work docker.io/library/alpine:latest \
    sh -lc "$cmd \"\$@\"" _ "$@"
}

# Example usage (pick one):
safe_run_firejail grep -R "TODO" .
# or
# safe_run_podman grep -R "TODO" .

What this does:

  • Allowlists only harmless text utilities.

  • Firejail: no network, private per-task directory.

  • Podman: no network, read-only rootfs, bind-mount a single working dir.


2) Make calls idempotent with a cheap content-addressed cache

Agents often repeat calls with the same prompt/context. Cache results by hashing inputs and saving the raw JSON.

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

CACHE_DIR="${CACHE_DIR:-.ai-cache}"
mkdir -p "$CACHE_DIR"

ai_hash() {
  # Hash the request-defining inputs (model, endpoint path, prompt, tools signature)
  printf '%s\0' "$AI_BASE_URL" "$AI_MODEL" "$1" "${2:-}" | sha256sum | awk '{print $1}'
}

ai_request() {
  local prompt="$1"
  local tools_sig="${2:-}"   # include a stable signature if tools/context change
  local key; key="$(ai_hash "$prompt" "$tools_sig")"
  local cache="$CACHE_DIR/$key.json"

  if [[ -f "$cache" ]]; then
    cat "$cache"
    return 0
  fi

  # Backoff/retry on transient failures
  local attempt=0 max=4 delay=1
  while (( attempt < max )); do
    if response="$(curl -sS --fail-with-body \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer ${AI_API_KEY:-}" \
      --max-time 30 \
      "$AI_BASE_URL/responses" \
      -d "$(jq -n --arg m "$AI_MODEL" --arg p "$prompt" '{model:$m,input:$p}')" )"; then
      printf '%s' "$response" > "$cache"
      printf '%s\n' "$response"
      return 0
    fi
    attempt=$((attempt+1))
    sleep "$delay"; delay=$((delay*2))
  done

  echo "Request failed after retries" >&2
  return 1
}

Benefits:

  • Deterministic retries that don’t blow past your usage limits.

  • Easy to purge or pin results by deleting files in .ai-cache/.


3) Log everything (structured!), then analyze with jq

Text logs are nice; JSONL is better. Keep request/response summaries for auditability and tuning.

#!/usr/bin/env bash
set -euo pipefail
LOG_DIR="${LOG_DIR:-logs}"
mkdir -p "$LOG_DIR"

log_event() {
  local type="$1" msg="$2"
  jq -nc --arg t "$(date -Is --utc)" --arg type "$type" --arg msg "$msg" \
    '{ts:$t,type:$type,msg:$msg}' >> "$LOG_DIR/agent.jsonl"
}

# Example: wrap ai_request to add logs
ai_ask() {
  local prompt="$1"
  log_event "request" "model=$AI_MODEL len=${#prompt}"
  local resp
  if ! resp="$(ai_request "$prompt")"; then
    log_event "error" "request_failed"
    return 1
  fi
  log_event "response" "bytes=${#resp}"
  printf '%s\n' "$resp"
}

You can filter later:

jq -r 'select(.type=="error")' logs/agent.jsonl

Mask secrets by never logging auth headers or env vars.


4) Parse model output defensively

Models differ in response shapes. Extract the “text” in a tolerant way and fail safe if it’s missing.

extract_text() {
  jq -r '
    .output[0].content[0].text? //
    .choices[0].message.content? //
    .message?.content? //
    .text? //
    empty
  '
}

# Usage
ai_ask "Summarize this log:" | extract_text || {
  echo "No text content found in response" >&2
  exit 1
}

This avoids brittle scripts that break when you swap providers or models.


5) Test before trust: add a tiny Bats test

Even one or two tests can catch regressions when you tweak prompts or upgrade dependencies.

Create test/extract_text.bats:

#!/usr/bin/env bats

@test "extract_text handles common shapes" {
  run bash -lc 'jq -n "{choices:[{message:{content:\"hello\"}}]}" | '"'"'extract_text'"'"
  [ "$status" -eq 0 ]
  [ "$output" = "hello" ]
}

Run tests:

bats test

If you haven’t yet:

  • Debian/Ubuntu: sudo apt install -y bats

  • Fedora/RHEL: sudo dnf install -y bats

  • openSUSE: sudo zypper install -y bats


Putting it together: a minimal .env and starter script

.env (do not commit this):

AI_BASE_URL=https://api.example.ai/v1
AI_API_KEY=sk-your-key
AI_MODEL=example-model

agent.sh:

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

# Load env safely
set -a
[ -f .env ] && . ./.env
set +a

source ./lib_cache.sh
source ./lib_logs.sh
source ./lib_sandbox.sh
source ./lib_extract.sh

prompt="List unique error messages from ./logs/app.log"
safe_run_firejail grep -oE "ERROR:.*" ./logs/app.log | sort | uniq > work/errors.txt

response_json="$(ai_ask "Summarize these errors:\n$(cat work/errors.txt)")"
summary="$(printf '%s' "$response_json" | extract_text)"
printf 'Summary:\n%s\n' "$summary"

This pattern:

  • Prepares data with allowlisted tools in a sandbox.

  • Calls the model with caching, retries, and logs.

  • Extracts text defensively.


Conclusion and next steps

You don’t need a monolithic “agent framework” to build reliable AI automation. The Unix toolbox already gives you:

  • Isolation (firejail, podman)

  • Idempotency (content-addressed caching)

  • Observability (JSONL logs + jq)

  • Safety (allowlists and fail-safe parsing)

  • Confidence (Bats tests)

Your next step: 1) Install the tools via your package manager. 2) Copy the snippets above into small, focused libs (cache, logs, sandbox, extract). 3) Start with a single agent task you run weekly—turn it into a safe, cached, logged script. 4) Iterate: add more tools to the allowlist only when needed, and test as you go.

If you want a ready-to-fork starter, drop these snippets into a repo with a Makefile target for test, run, and clean. Then grow your agent’s capabilities one guarded command at a time.