Set up a Claude Code statusline on a VPS
The statusLine setting runs a script and prints its stdout under the prompt. Show hostname, directory, git branch and model, so you edit the right server.
What a Claude Code statusline shows
A Claude Code statusline is a row under the prompt that displays the output of a script you write. You add a statusLine block to settings.json and point it at a command. Claude Code runs that command, sends the session state to it as JSON on standard input, and prints whatever the command writes to standard output.
That is the whole contract. Your script reads JSON on stdin and prints text on stdout. It runs on your machine and nothing it prints is sent to the model, so it costs no tokens.
On a laptop with one project this is decoration. On three servers it is a safety rail. Every Claude Code session looks the same in every terminal, so four SSH windows with no labels is how a migration lands on the wrong box. A statusline that starts with the hostname ends that class of mistake.
Where the statusLine setting lives in settings.json
Put it in your user settings at ~/.claude/settings.json, which applies to every project on that machine. Project settings at .claude/settings.json inside a repository work too, and they win for that directory.
{
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh"
}
}type is always "command". The command value runs through a shell, so it can be a script path or a plain command. Prove the wiring works before you write any script:
{
"statusLine": {
"type": "command",
"command": "hostname -s"
}
}Start Claude Code and send one message. The bar under the prompt now reads the short hostname of the server. If it stays empty, the problem is the setting or the trust dialog, not your script. Read "Why the statusline stays blank" below.
Three optional keys exist as of August 2026. padding adds horizontal spacing in characters and defaults to 0. refreshInterval re-runs the command every N seconds on top of the normal triggers, with a minimum of 1, which you want only when the line shows a clock or something that changes while the session sits idle. hideVimModeIndicator suppresses the built-in -- INSERT -- text when your own script already renders the vim mode.
What data does the statusline script receive?
Do not trust a field list you read anywhere, including this page. Capture the real object your version sends. Write a throwaway script that saves stdin to a file:
cat > ~/.claude/statusline-capture.sh <<'EOF'
#!/bin/bash
cat > /tmp/statusline-input.json
echo "captured"
EOF
chmod +x ~/.claude/statusline-capture.shPoint statusLine.command at that file, start a session, and send one message. The bar reads captured. Now look at what arrived:
jq . /tmp/statusline-input.jsonYou have the exact shape for your build, and you can repeat this any time an update changes something.
The stable parts, as documented in August 2026, are nested objects rather than flat keys. model holds id and display_name. workspace holds current_dir and project_dir: current_dir is where the session is now, project_dir is where it was launched, and the two differ once the working directory changes mid-session. The top-level cwd carries the same value as workspace.current_dir. context_window holds token counts plus a pre-calculated used_percentage. cost holds total_cost_usd and duration counters. session_id is stable for the life of the session and unique across sessions, which matters for caching later.
Three rules keep a script alive across schema changes.
Some keys are absent, not null. vim, agent, pr, worktree and effort appear only when the matching feature is active. Reading .vim.mode with jq -r while vim mode is off prints the literal string null, and your bar shows null to the reader. Append // empty to every selector, so a missing key prints nothing at all.
Some values are null early. context_window.used_percentage and context_window.current_usage are null before the first API response, and current_usage returns to null after /compact until the next call repopulates it. A context percentage on the bar therefore needs // 0, or it reads null for the first seconds of every session. Before you put that number on a bar, it helps to know how the context window actually fills up.
The git branch is not in the JSON. No field reports it. Any branch on your bar comes from your script running git itself.
A statusline script that degrades instead of breaking
This is the copy-paste version. It prints hostname, working directory, git branch and model name. Every field has a fallback, so even an empty JSON object still produces a usable line.
#!/bin/bash
# ~/.claude/statusline.sh
input=$(cat)
# Read one field. Prints nothing when the key is missing or null.
field() { printf '%s' "$input" | jq -r "$1 // empty" 2>/dev/null; }
HOST=$(hostname -s 2>/dev/null)
[ -z "$HOST" ] && HOST="host"
DIR=$(field '.workspace.current_dir')
[ -z "$DIR" ] && DIR=$(field '.cwd')
[ -z "$DIR" ] && DIR="$PWD"
MODEL=$(field '.model.display_name')
[ -z "$MODEL" ] && MODEL="claude"
SHORT="$DIR"
if [ -n "$HOME" ]; then
case "$DIR" in
"$HOME") SHORT="~" ;;
"$HOME"/*) SHORT="~/${DIR#"$HOME"/}" ;;
esac
fi
BRANCH=""
if git -C "$DIR" rev-parse --git-dir >/dev/null 2>&1; then
BRANCH=$(git -C "$DIR" branch --show-current 2>/dev/null)
[ -z "$BRANCH" ] && BRANCH="detached"
fi
CYAN=$'\033[36m'
YELLOW=$'\033[33m'
DIM=$'\033[2m'
RESET=$'\033[0m'
LINE="${CYAN}${HOST}${RESET} ${SHORT}"
[ -n "$BRANCH" ] && LINE="${LINE} ${YELLOW}${BRANCH}${RESET}"
LINE="${LINE} ${DIM}${MODEL}${RESET}"
printf '%s\n' "$LINE"Every read goes through field, which appends // empty, so a renamed or removed key produces an empty string and the next line supplies a default. The directory falls back from workspace.current_dir to cwd to $PWD. The branch comes from git -C "$DIR" rather than a bare git, so the branch always matches the directory the bar is showing.
Save it, then make it executable:
chmod +x ~/.claude/statusline.shThe execute bit is not optional. Claude Code runs the command through a shell, so a script without +x fails with Permission denied, produces no stdout, and the row stays blank with no visible error.
jq parses JSON on the command line and is not installed on a fresh Ubuntu server:
sudo apt update && sudo apt install -y jqThen point the setting at the script, using the first settings.json block above.
Test the script before you trust it
Run it twice by hand. First with a normal session object:
echo '{"model":{"display_name":"Opus"},"workspace":{"current_dir":"/srv/api"},"session_id":"t1"}' | ~/.claude/statusline.shYou get the hostname, then /srv/api, then Opus. No branch appears, because /srv/api on your machine is probably not a git repository.
Second, the degradation test, which is the one people skip:
echo '{}' | ~/.claude/statusline.shAn empty object is the worst case a schema change can hand you. The line still prints: the hostname, the current directory from $PWD, and the word claude where the model name goes. Nothing crashes and nothing prints null. A script that passes this test survives a field being renamed, because to your script a renamed field and a missing field are the same event.
What you should see
The statusline renders on its own row above the built-in footer badges and does not replace them. On a working setup it is one row: the short hostname in cyan, then the working directory with your home directory collapsed to ~, then the branch name in yellow when the directory is a git repository, then the model name dimmed. Something close to web-01 ~/api main Opus, with those four pieces coloured.
The row re-runs your script when a session starts, including a resume, when a new assistant message arrives, after /compact finishes, when the permission mode changes, when vim mode toggles, and on a refreshInterval tick if you set one. Updates are debounced at 300 ms, so a burst of changes runs the script once. The bar hides during autocomplete, the help menu and permission prompts, then returns.
Why the hostname belongs first
When you keep agents running on more than one server, the terminal is the only thing telling you where you are, and terminals lie. Open a second ssh connection from inside a tmux pane and the window title often keeps the old name, because the title is set by a shell that never learned it moved. Leave Claude Code running in a detached tmux session on a VPS and reattach a day later, and nothing on screen separates the build server from the production box.
The statusline is different because it is rendered by Claude Code itself, per session, from data that session holds. It cannot be inherited from the wrong pane or left stale by a shell prompt that never refreshed. What it says is the box the agent is writing files on.
Give each server its own colour so you recognise it before you read it. Two lines, dropped in above the LINE= assignment:
CODE=$(printf '%s' "$HOST" | cksum | cut -d' ' -f1)
HOST_COLOR=$(printf '\033[%dm' "$((31 + CODE % 6))")Then use ${HOST_COLOR} in place of ${CYAN}. cksum prints a checksum of the hostname, so a given name always maps to the same colour in the range 31 to 36, which is red through cyan. Copy the same script to every box and each one labels itself.
The directory earns its place for the same reason. /srv/api and /srv/api-staging are one keystroke apart in an ssh command and a whole incident apart in effect. Model and branch are the other two worth the width: the model tells you which session you resumed, and the branch tells you whether the agent is about to commit onto main.
A small screen makes all of this sharper, since there is no window title to fall back on. If that is your setup, see driving Claude Code from a phone.
Keep the script fast
Your script runs on every assistant message, and Claude Code cancels an in-flight run when a new update arrives. A slow script therefore shows stale text, or no text.
Each jq call costs a few milliseconds. git is the part that gets slow: git status in a large repository with a cold cache takes hundreds of milliseconds. The script above avoids git status on purpose and calls git branch --show-current, which reads .git/HEAD and returns immediately.
If you add something heavier, cache it in a file and refresh it every few seconds. Key the file on the session:
CACHE="/tmp/statusline-$(field '.session_id')"Use session_id, not $$. $$ is the process ID of your script, which is different on every single invocation, so a cache keyed on it never hits and you pay the full cost every time. session_id is stable for the whole session and different between sessions, so two Claude Code sessions in two repositories cannot read each other's cached branch name.
One more limit worth knowing: tput cols does not work inside a statusline script. Claude Code captures the output instead of attaching your script to the terminal, so width detection has nothing to measure. Claude Code sets the COLUMNS and LINES environment variables before running the command, in v2.1.153 and later, so read $COLUMNS when you need to decide how much to print.
Why the statusline stays blank
Nothing appears at all. Check the execute bit with ls -l ~/.claude/statusline.sh, then run the script by hand with the mock input above. If it prints a line at the shell but not in Claude Code, start with claude --debug, which logs the exit code and the stderr of the first statusline run of the session.
The debug log says Status line command skipped: workspace trust not accepted. The statusline executes a shell command, so it sits behind the same workspace trust gate as hooks. Until you accept the trust dialog for that directory, the command never runs. This is common on a VPS, where every new clone is a directory Claude Code has not seen. Restart Claude Code in that directory and accept the dialog.
Everything is blank and disableAllHooks is set. "disableAllHooks": true in settings.json disables the statusline too, because it is the same shell-execution gate. Remove it or set it to false.
The row prints null. A jq selector reached a key that is missing or null, and jq -r prints null as the four characters null. Add // empty for text and // 0 for numbers.
The row goes blank right after you edit the script. A command that exits non-zero, or prints nothing, blanks the row. The usual cause is a final line like [ -n "$BRANCH" ] && LINE="...", which exits 1 when the branch is empty and takes the whole script's exit code with it. Keep the printf last, or add exit 0.
Escape codes show as literal text such as \e]8;; on the bar. Use printf '%b' instead of echo -e. Clickable OSC 8 links also need a terminal that supports them, and tmux or SSH may strip the sequences, so plain colour is the safer choice on a remote box.
The right side of the row is cut off. System notifications and the verbose-mode token counter share that row from the right, and a narrow terminal loses the overlap. Keep the output short. For a real accounting of usage rather than a number on a bar, see how Claude Code counts tokens.
FAQ
Where does the Claude Code statusline setting live?
In settings.json, as a statusLine block with type set to "command" and command set to a script path or a shell command. User settings are at ~/.claude/settings.json and apply to every project on that machine. Project settings are at .claude/settings.json inside the repository and win for that directory. Settings reload on their own, but a change only becomes visible on the next update trigger, such as your next message.
Why is my Claude Code statusline blank?
Four causes cover nearly all of it. The script is missing the execute bit, so the shell returns Permission denied and nothing reaches stdout. The workspace trust dialog was never accepted, and claude --debug logs Status line command skipped: workspace trust not accepted. disableAllHooks is true, which disables the statusline under the same gate. Or the script exits non-zero, which blanks the row. Test it by hand first: echo '{}' | ~/.claude/statusline.sh has to print something.
Does the statusline JSON include the git branch?
No. The JSON carries session state such as the model, the workspace directories, context window numbers and cost. Nothing in it reports git. A branch on your bar comes from your own script calling git branch --show-current. Pass the directory from the JSON with git -C "$DIR", so the branch always matches the directory the bar is showing.
Does a statusline cost tokens or slow the session down?
It costs no tokens, because the script runs locally and its output is never sent to the model. Speed is your responsibility. The command runs on every assistant message with a 300 ms debounce, and Claude Code cancels an in-flight run when a new update arrives, so a script taking a full second shows stale text. Avoid git status in large repositories, and cache anything slow in a file keyed on session_id.
How do I show a different statusline on each server?
Keep one script and let it read the machine. The script above prints $HOSTNAME with hostname -s as a fallback, so the same file copied to every box labels each one correctly, and the checksum colour trick gives each hostname its own colour. If one server needs a different layout, put a statusLine block in the project settings of the repository you work in on that box, since project settings override user settings for that directory.