Skip to content

Instantly share code, notes, and snippets.

@fukata
Created August 12, 2026 18:33
Show Gist options
  • Select an option

  • Save fukata/de3a182e0bba7b49f588054259e33f0c to your computer and use it in GitHub Desktop.

Select an option

Save fukata/de3a182e0bba7b49f588054259e33f0c to your computer and use it in GitHub Desktop.
herdr-layout — declarative workspace layouts for herdr via its undocumented layout.apply / layout.export socket API

herdr-layout — declarative workspace layouts for herdr

zellij has zellij --layout=foo.kdl. tmux has tmuxinator. herdr has no documented equivalent — but it turns out the engine is already there, it's just not wired up to the CLI.

herdr's socket API exposes three layout methods that appear in neither herdr --help nor the docs at herdr.dev:

method what it does
layout.apply build an entire tab from a declarative tree, in one request
layout.export dump an existing tab back out as that same tree
layout.set_split_ratio adjust a split

herdr-layout is a ~120-line shell wrapper over layout.apply / layout.export. That's all it is — herdr does the actual work.

Verified against herdr 0.8.0, protocol 19.


Why bother (vs. scripting the CLI)

You can build a layout with herdr pane split in a loop. It's worse in four ways:

  1. Split order is a puzzle. Panes form a binary tree, so a 2×2 grid needs split-down-then-right-on-each-row in exactly the right sequence. A declarative tree just says what you want.
  2. pane split has no --command. You split, then herdr pane run <id> '...' types the command into the pane's shell — which means sleeping until the prompt is ready and hoping you win the race. layout.apply launches the command as the pane's process, no shell wrapper, no race. (Confirmed: the pane's shell_pid is the command's pid.)
  3. One request per tab instead of N splits + N renames, each parsed with jq.
  4. layout.export means you never hand-write a layout. Drag panes around in the TUI until it looks right, then dump it to a file.

Requirements

  • herdr ≥ 0.8.0 running (herdr status server)
  • jq — 1.5 or newer (the script defines walk/1 itself, since 1.5 lacks it)
  • nc with unix-socket support (-U) — OpenBSD netcat, the default on Ubuntu/Debian/macOS. GNU netcat (nc.traditional) will not work.

No runtime, no build step, no package manager.

# check your nc
printf '{"id":"x","method":"ping","params":{}}' | nc -U ~/.config/herdr/herdr.sock
# → {"id":"x","result":{"type":"pong","version":"0.8.0",...}}

Install

curl -o ~/bin/herdr-layout https://gist.githubusercontent.com/fukata/de3a182e0bba7b49f588054259e33f0c/raw/herdr-layout
chmod +x ~/bin/herdr-layout

Usage

herdr-layout <layout.json>                  # build the workspace
herdr-layout <layout.json> --replace        # tear down an existing one first
herdr-layout <layout.json> --no-commands    # build the shape, run nothing
herdr-layout --export                       # dump the focused workspace
herdr-layout --export --workspace w3        # dump a specific one
flag effect
--replace close any existing workspace with the same label, then rebuild
--no-commands strip every command — panes open as plain shells. Good for checking geometry before you let daemons loose
--export print the workspace as a layout file on stdout
--workspace <id> target for --export (default: $HERDR_WORKSPACE_ID, else the focused one)
--socket <path> non-default server socket (also $HERDR_SOCKET)

The authoring loop

Don't write the JSON by hand. Build it in the TUI, then:

herdr-layout --export > ~/.config/herdr/layouts/myapp.json

Paths under $HOME are rewritten back to ~/ on export and expanded again on apply, so one file works across machines — handy if you sync your dotfiles.


Layout file format

{
  "label": "myapp",          // workspace name; also the --replace key
  "cwd": "~/src/myapp",      // default cwd for the workspace
  "tabs": [
    {
      "label": "code",       // tab title
      "focus": true,         // optional; this tab is active after building
      "root": { /* node */ }
    }
  ]
}

A node is either a pane (leaf) or a split (branch):

// leaf
{
  "type": "pane",
  "label": "server",              // pane title
  "cwd": "~/src/myapp",           // per-pane — splits across repos are fine
  "command": ["npm", "run", "dev"],  // argv, NOT a shell string. Omit for a shell.
  "env": { "PORT": "3000" }
}

// branch — always exactly two children
{
  "type": "split",
  "direction": "right",   // "right" = side by side | "down" = stacked
  "ratio": 0.5,           // 0–1, share given to "first"
  "first":  { /* node */ },
  "second": { /* node */ }
}

Every field except type (and a split's direction/ratio/first/second) is optional.

command is argv, not a shell line

"command": ["npm", "run", "dev"]           //
"command": ["npm run dev"]                 // ❌ looks for a binary named "npm run dev"
"command": ["bash", "-lc", "a && b"]       // ✅ when you really want a shell

The process is launched directly in the pane — exactly like zellij's command/args. When it exits, the pane closes.

Nesting: a 2×2 grid

Splits are binary, so a grid is a split of splits. This gives a tall left pane with two stacked on the right, and a full-width row beneath:

{
  "type": "split", "direction": "down", "ratio": 0.5,
  "first": {
    "type": "split", "direction": "right", "ratio": 0.5,
    "first":  { "type": "pane", "label": "a" },
    "second": {
      "type": "split", "direction": "down", "ratio": 0.5,
      "first":  { "type": "pane", "label": "b" },
      "second": { "type": "pane", "label": "c" }
    }
  },
  "second": {
    "type": "split", "direction": "right", "ratio": 0.5,
    "first":  { "type": "pane", "label": "d" },
    "second": { "type": "pane", "label": "e" }
  }
}
┌─────────┬─────────┐
│         │    b    │
│    a    ├─────────┤
│         │    c    │
├─────────┴─────────┤
│    d    │    e    │
└─────────┴─────────┘

See example.json for a complete file.


Shell integration

A peco picker, in the spirit of zellij --layout=$(find ... | peco):

function h() {
  local layout
  layout=$(find ~/.config/herdr/layouts -type f -name '*.json' | sort | peco) || return 1
  [ -n "$layout" ] || return 1

  # the API needs a server; start one headless if there isn't one
  if ! herdr status server 2>/dev/null | grep -q '^status: running'; then
    nohup herdr server >/dev/null 2>&1 &
    local i; for i in $(seq 50); do
      herdr status server 2>/dev/null | grep -q '^status: running' && break
      sleep 0.1
    done
  fi

  herdr-layout "$layout" "$@" || return $?
  [ "${HERDR_ENV:-}" = 1 ] || herdr   # attach only if we're outside herdr
}

Works from a cold boot — no need to run herdr first.


Gotchas

layout.apply replaces the tab and returns a new tab_id. Apply to w1:t1 and you get back w1:t3. Read it from .result.layout.tab_id; the old id is gone. This is the one thing that will silently break a hand-rolled script.

herdr restores your session on restart. session.json persists workspaces, tabs, panes and cwd — so after a reboot your layout comes back shaped but dead: commands are not stored, so nothing is running. A second herdr-layout foo.json will then refuse ("already exists"). That's what --replace is for.

tab.focused lies when the workspace isn't focused. It reflects live UI state, so every tab reports false from a background workspace. Use workspace.get → active_tab_id instead. --export already does.

A cwd that doesn't exist fails silently. The pane opens in $HOME instead, and layout.apply still reports success. Typo a path and you get a workspace that looks right and is entirely wrong — check with herdr pane list --workspace <id> | jq -r '.result.panes[].cwd' after building.

snap-installed jq can't read /tmp. Confinement. $HOME and /mnt/... are fine. The script pipes layout files through stdin to sidestep it entirely.

One request per connection. The server closes the socket after each response, so each call is its own nc. Convenient — nc exits on its own, no -w/-q timeout hacks.


Poking at the API yourself

SOCK=~/.config/herdr/herdr.sock

# every request: {"id": ..., "method": ..., "params": {...}}
printf '{"id":"x","method":"workspace.list","params":{}}' | nc -U $SOCK | jq

# the full schema, including every layout type
herdr api schema --json | jq '.schemas.request["$defs"].LayoutNode'

herdr api schema --json is the authority — it documents far more than the CLI surfaces. LayoutNode, LayoutApplyParams and LayoutExportParams are all in there.


Licence

Public domain / CC0. It's a wrapper around someone else's good idea.

{
"label": "myapp",
"cwd": "~/src/myapp",
"tabs": [
{
"label": "code",
"root": {
"type": "split", "direction": "right", "ratio": 0.6,
"first": {
"type": "pane", "label": "editor",
"cwd": "~/src/myapp",
"command": ["nvim", "."]
},
"second": {
"type": "split", "direction": "down", "ratio": 0.5,
"first": {
"type": "pane", "label": "shell",
"cwd": "~/src/myapp"
},
"second": {
"type": "pane", "label": "git",
"cwd": "~/src/myapp",
"command": ["lazygit"]
}
}
}
},
{
"label": "services",
"root": {
"type": "split", "direction": "down", "ratio": 0.5,
"first": {
"type": "pane", "label": "server",
"cwd": "~/src/myapp",
"command": ["npm", "run", "dev"],
"env": { "PORT": "3000" }
},
"second": {
"type": "pane", "label": "worker",
"cwd": "~/src/myapp/worker",
"command": ["npm", "run", "worker"]
}
}
},
{
"label": "shell",
"focus": true,
"root": {
"type": "pane", "label": "shell",
"cwd": "~/src/myapp"
}
}
]
}
#!/usr/bin/env bash
# herdr-layout — declarative workspace layouts for herdr, like zellij's --layout.
#
# herdr's socket API has layout.apply / layout.export, but the CLI does not
# expose them. This script is a thin wrapper over those two methods.
#
# herdr-layout <layout.json> [--replace] [--no-commands] apply a layout
# herdr-layout --export [--workspace <id>] dump one as JSON
#
# Deps: jq, nc (with -U / unix-socket support). Both ship with Ubuntu/macOS.
set -euo pipefail
SOCK="${HERDR_SOCKET:-$HOME/.config/herdr/herdr.sock}"
REPLACE=0
NO_COMMANDS=0
EXPORT=0
WS_ARG=""
FILE=""
die() { echo "herdr-layout: $*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--replace) REPLACE=1 ;;
--no-commands) NO_COMMANDS=1 ;;
--export) EXPORT=1 ;;
--workspace) shift; WS_ARG="${1:-}" ;;
--socket) shift; SOCK="${1:-}" ;;
-h|--help)
sed -n '2,11p' "$0" | sed 's/^# \?//'
exit 0 ;;
-*) die "unknown option: $1" ;;
*) FILE="$1" ;;
esac
shift
done
if [ "$EXPORT" = 0 ]; then
[ -n "$FILE" ] || die "usage: herdr-layout <layout.json> [--replace] [--no-commands]"
[ -f "$FILE" ] || die "no such layout file: $FILE"
fi
command -v jq >/dev/null || die "jq is required"
command -v nc >/dev/null || die "nc is required"
[ -S "$SOCK" ] || die "herdr server socket not found: $SOCK (start it with: herdr)"
# Probe the socket: this also proves this nc build supports -U (GNU netcat does not).
if ! printf '%s\n' '{"id":"probe","method":"ping","params":{}}' \
| nc -U "$SOCK" 2>/dev/null | grep -q '"pong"'; then
die "cannot talk to $SOCK — is the server running, and does your nc support -U? (needs openbsd-netcat)"
fi
# --- socket helpers -------------------------------------------------------
# The server closes the connection after each response, so one nc per request.
api() { # api <method> <params-json>
local res
res=$(jq -nc --arg m "$1" --argjson p "$2" '{id:"herdr-layout",method:$m,params:$p}' | nc -U "$SOCK")
[ -n "$res" ] || die "empty response from server ($1)"
if [ "$(printf '%s' "$res" | jq -r 'has("error")')" = true ]; then
die "$1 failed: $(printf '%s' "$res" | jq -r '.error.message')"
fi
printf '%s' "$res"
}
# jq 1.5 has no builtin walk/1, so define it (works on 1.5+).
WALK='def walk(f): . as $in | if type=="object" then reduce keys_unsorted[] as $k ({}; . + {($k): ($in[$k]|walk(f))}) | f elif type=="array" then map(walk(f)) | f else f end;'
# --- export ---------------------------------------------------------------
# Build a layout interactively in the TUI, then dump it as a reusable file.
if [ "$EXPORT" = 1 ]; then
WS_ID="${WS_ARG:-${HERDR_WORKSPACE_ID:-}}"
if [ -z "$WS_ID" ]; then
WS_ID=$(api workspace.list '{}' | jq -r '.result.workspaces[] | select(.focused) | .workspace_id')
fi
[ -n "$WS_ID" ] || die "could not determine workspace; pass --workspace <id>"
WS_INFO=$(api workspace.get "$(jq -nc --arg w "$WS_ID" '{workspace_id:$w}')")
WS_LABEL=$(printf '%s' "$WS_INFO" | jq -r '.result.workspace.label // "layout"')
# tab.focused only reflects the live UI; active_tab_id is the durable signal.
ACTIVE_TAB=$(printf '%s' "$WS_INFO" | jq -r '.result.workspace.active_tab_id // ""')
TABS=$(api tab.list "$(jq -nc --arg w "$WS_ID" '{workspace_id:$w}')")
OUT=$(jq -nc --arg l "$WS_LABEL" '{label:$l, tabs:[]}')
for t in $(printf '%s' "$TABS" | jq -r '.result.tabs[].tab_id'); do
T_LABEL=$(printf '%s' "$TABS" | jq -r --arg t "$t" '.result.tabs[]|select(.tab_id==$t)|.label // ""')
if [ "$t" = "$ACTIVE_TAB" ]; then T_FOCUS=true; else T_FOCUS=false; fi
ROOT=$(api layout.export "$(jq -nc --arg t "$t" '{tab_id:$t}')" \
| jq -c "$WALK"' .result.layout.root
| walk(if type=="object" and has("pane_id") then del(.pane_id) else . end)')
OUT=$(printf '%s' "$OUT" | jq -c --arg l "$T_LABEL" --argjson f "$T_FOCUS" --argjson r "$ROOT" \
'.tabs += [ (if $l=="" then {} else {label:$l} end)
+ (if $f then {focus:true} else {} end)
+ {root:$r} ]')
done
# Collapse absolute $HOME paths back to ~/ so the file stays portable.
printf '%s' "$OUT" | jq --arg h "$HOME" "$WALK"'
walk(if type=="string" then sub("^"+$h+"/"; "~/") else . end)'
exit 0
fi
# --- load layout ----------------------------------------------------------
# Read via stdin: snap-confined jq cannot open files under /tmp.
# Expand ~/ and $HOME/ so one layout file works across machines.
LAYOUT=$(cat "$FILE" | jq -c --arg h "$HOME" "$WALK"'
walk(if type=="string" then (sub("^~/";$h+"/")|sub("^\\$HOME/";$h+"/")) else . end)')
if [ "$NO_COMMANDS" = 1 ]; then
LAYOUT=$(printf '%s' "$LAYOUT" | jq -c "$WALK"'
walk(if type=="object" and has("command") then del(.command) else . end)')
fi
LABEL=$(printf '%s' "$LAYOUT" | jq -r '.label // empty')
[ -n "$LABEL" ] || die "layout file has no \"label\""
ROOT_CWD=$(printf '%s' "$LAYOUT" | jq -r '.cwd // empty')
TAB_COUNT=$(printf '%s' "$LAYOUT" | jq '.tabs | length')
[ "$TAB_COUNT" -gt 0 ] || die "layout file has no tabs"
# --- existing workspace ---------------------------------------------------
EXISTING=$(api workspace.list '{}' \
| jq -r --arg l "$LABEL" '.result.workspaces[] | select(.label==$l) | .workspace_id')
if [ -n "$EXISTING" ]; then
if [ "$REPLACE" = 1 ]; then
for w in $EXISTING; do
echo "replacing existing workspace $w ($LABEL)"
api workspace.close "$(jq -nc --arg w "$w" '{workspace_id:$w}')" >/dev/null
done
else
die "workspace \"$LABEL\" already exists ($EXISTING); use --replace to rebuild"
fi
fi
# --- build ----------------------------------------------------------------
WS=$(api workspace.create "$(jq -nc --arg l "$LABEL" --arg c "$ROOT_CWD" \
'{label:$l, focus:false} + (if $c=="" then {} else {cwd:$c} end)')")
WS_ID=$(printf '%s' "$WS" | jq -r '.result.workspace.workspace_id')
FIRST_TAB=$(printf '%s' "$WS" | jq -r '.result.tab.tab_id')
FOCUS_TAB=""
i=0
while [ "$i" -lt "$TAB_COUNT" ]; do
TAB_JSON=$(printf '%s' "$LAYOUT" | jq -c --argjson i "$i" '.tabs[$i]')
TAB_LABEL=$(printf '%s' "$TAB_JSON" | jq -r '.label // empty')
TAB_CWD=$(printf '%s' "$TAB_JSON" | jq -r '.cwd // empty')
if [ "$i" -eq 0 ]; then
TAB_ID="$FIRST_TAB"
else
NEW=$(api tab.create "$(jq -nc --arg w "$WS_ID" --arg c "$TAB_CWD" \
'{workspace_id:$w, focus:false} + (if $c=="" then {} else {cwd:$c} end)')")
TAB_ID=$(printf '%s' "$NEW" | jq -r '.result.tab.tab_id')
fi
# layout.apply replaces the tab, so the applied tab gets a new id.
APPLIED=$(api layout.apply "$(printf '%s' "$TAB_JSON" | jq -c --arg t "$TAB_ID" --arg l "$TAB_LABEL" \
'{tab_id:$t, root:.root} + (if $l=="" then {} else {tab_label:$l} end)')")
TAB_ID=$(printf '%s' "$APPLIED" | jq -r '.result.layout.tab_id')
if [ "$(printf '%s' "$TAB_JSON" | jq -r '.focus // false')" = true ]; then
FOCUS_TAB="$TAB_ID"
fi
i=$((i + 1))
done
if [ -n "$FOCUS_TAB" ]; then
api tab.focus "$(jq -nc --arg t "$FOCUS_TAB" '{tab_id:$t}')" >/dev/null
fi
api workspace.focus "$(jq -nc --arg w "$WS_ID" '{workspace_id:$w}')" >/dev/null
echo "$WS_ID ($LABEL): $TAB_COUNT tab(s) applied"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment