n4nAI

Building a CLI chatbot in under 50 lines of bash

Build a minimal bash CLI chatbot script that talks to any OpenAI-compatible LLM endpoint, keeps conversation history, and runs in under 50 lines of shell.

n4n Team3 min read609 words

Audio narration

Coming soon — every post will get a voice note here.

A bare-bones bash cli chatbot script is the fastest way to put a model in front of your terminal workflow. This tutorial builds one in under 50 lines of POSIX-ish bash that speaks to any OpenAI-compatible /chat/completions endpoint, keeps conversation state, and degrades cleanly on API errors.

Prerequisites

You need a Unix-like shell environment with the following:

  • bash 4.4 or newer (tested on 5.2)
  • curl for HTTP
  • jq 1.6+ for JSON manipulation
  • An API key and base URL for an OpenAI-compatible endpoint

Export your credentials before running anything:

export OPENAI_API_KEY="sk-your-key"
export BASE_URL="https://api.openai.com/v1"   # or your own proxy

If you want broad model coverage and automatic fallback when a provider is degraded, n4n.ai exposes a single OpenAI-compatible endpoint across 240+ models; the script below works unchanged against it.

Why a bash cli chatbot script

Python or Node are obvious choices for LLM calls, but shell wins when the bot is a glue step in a pipeline or a quick internal tool. No virtualenv, no npm install, just curl and jq that are already on most servers. The bash cli chatbot script we build here drops into a Makefile, a CI hook, or a tmux binding without dragging a runtime along.

Step 1: Configuration and state

We keep the entire conversation in a JSON array stored in a shell variable. Seed it with a system prompt using jq -n so we never hand-write JSON braces in bash.

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

BASE_URL="${BASE_URL:-https://api.openai.com/v1}"
API_KEY="${OPENAI_API_KEY:?Set OPENAI_API_KEY}"
MODEL="${MODEL:-gpt-4o-mini}"
HISTORY=$(jq -n '[{"role":"system","content":"You are a terse terminal assistant."}]')

set -euo pipefail makes the script abort on undefined variables and failed pipes. The ${VAR:?msg} syntax forces a clear error if the key is missing.

Step 2: Sending a turn

Define a function that appends the user message, posts to the API, extracts the reply, and appends it back to history.

call_api() {
  local user_msg="$1"
  HISTORY=$(echo "$HISTORY" | jq --arg m "$user_msg" '. + [{"role":"user","content":$m}]')
  local resp
  resp=$(curl -sS "$BASE_URL/chat/completions" \
    -H "Authorization: Bearer $API_KEY" \
    -H "Content-Type: application/json" \
    -d "$(echo "$HISTORY" | jq -c --arg m "$MODEL" '{model:$m, messages:.}')")
  local reply
  reply=$(echo "$resp" | jq -r '.choices[0].message.content')
  HISTORY=$(echo "$HISTORY" | jq --arg r "$reply" '. + [{"role":"assistant","content":$r}]')
  printf '%s\n' "$reply"
}

We build the request body with jq -c to emit compact JSON. The API returns a choices array; we pull the first message content with -r to drop JSON quoting. Because HISTORY is reassigned on every call, context survives across turns.

Checkpoint: source the script and call the function once.

$ bash -c 'source ./chat.sh; call_api "Say hi in three words."'
Hi there, friend.

If you see the reply, your endpoint and key are correct.

Step 3: The REPL loop

Wrap call_api in a read loop with a prompt. Skip empty lines and support /exit.

echo "Chatting with $MODEL. Type /exit to quit."
while true; do
  printf '> '
  line=""
  IFS= read -r line || break
  [[ -z "$line" ]] && continue
  [[ "$line" == "/exit" ]] && break
  call_api "$line"
done

IFS= read -r preserves leading spaces and backslashes—important if the user pastes code. The || break handles EOF (Ctrl-D) gracefully.

The bash cli chatbot script now maintains context because HISTORY is a global variable mutated inside call_api.

Step 4: Hardening and trade-offs

For a 30-line script we rely on set -e to abort if curl fails (network down, 401, etc.). That is acceptable for interactive use. If you need structured error reporting, capture the HTTP status:

resp=$(curl -sS -w '\n%{http_code}' "$BASE_URL/chat/completions" ...)
code=$(echo "$resp" | tail -1)
body=$(echo "$resp" | sed '$d')
[[ "$code" == "200" ]] || { echo "API $code: $body" >&2; exit 1; }

That adds four lines. Under 50 total is still easy.

Do not echo $API_KEY or $resp in debug logs. The key lives only in the Authorization header and never touches disk.

The full script

Here is the complete, runnable file. It is 30 lines including shebang and blanks.

#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-https://api.openai.com/v1}"
API_KEY="${OPENAI_API_KEY:?Set OPENAI_API_KEY}"
MODEL="${MODEL:-gpt-4o-mini}"
HISTORY=$(jq -n '[{"role":"system","content":"You are a terse terminal assistant."}]')

call_api() {
  HISTORY=$(echo "$HISTORY" | jq --arg m "$1" '. + [{"role":"user","content":$m}]')
  local resp
  resp=$(curl -sS "$BASE_URL/chat/completions" \
    -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
    -d "$(echo "$HISTORY" | jq -c --arg m "$MODEL" '{model:$m,messages:.}')")
  local reply
  reply=$(echo "$resp" | jq -r '.choices[0].message.content')
  HISTORY=$(echo "$HISTORY" | jq --arg r "$reply" '. + [{"role":"assistant","content":$r}]')
  printf '%s\n' "$reply"
}

echo "Chatting with $MODEL. Type /exit to quit."
while true; do
  printf '> '
  line=""
  IFS= read -r line || break
  [[ -z "$line" ]] && continue
  [[ "$line" == "/exit" ]] && break
  call_api "$line"
done

Save as chat.sh, chmod +x, and run.

Expected output

$ ./chat.sh
Chatting with gpt-4o-mini. Type /exit to quit.
> what bash flag ignores errors?
-e
> and pipe failures?
-o pipefail
> /exit

The bash cli chatbot script kept both questions in HISTORY, so the model answered the second with context from the first.

Extensions

  • Streaming: use curl -N and parse data: lines with awk. Adds ~12 lines.
  • Persistent history: on exit, echo "$HISTORY" > ~/.chat_history.json and load it at start with jq.
  • Gateway routing: if your endpoint honors client routing directives, add "route" to the request body. n4n.ai automatically falls back when a provider is rate-limited, so the same script survives provider outages without extra code.
  • Cache control: gateways that forward provider cache-control hints let you mark the system prompt with "cache_control":{"type":"ephemeral"} to cut repeat token costs across sessions.
  • Token metering: capture .usage from the response and append to a local counter for per-token accounting.

Shell is not the place for complex retry or concurrency logic. Keep the bash cli chatbot script lean and push resilience to the endpoint or a thin wrapper.

Tagsbashclichatbotshell-scripting

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All bash/shell scripting llm automation posts →