← All build notes

November 13, 2026 · 4 min read

A kill switch for agents that act outside the machine

The pause check behind AIWG's agent-run publishing this month: a flag file and a tracker label, a script and wrapper that fail closed, the latent bug that...

This month agents do most of the outward work on AIWG: they push, tag releases, write to the tracker, publish to aiwg.io and post on our social accounts. The charter for the month requires three independent ways to stop all of it, and any one of them is enough:

  1. a message from me in the agent's session;
  2. a file, ~/.config/aiwg-autonomy/PAUSE;
  3. the label autonomy:paused on the month's tracking issue.

The message covers the session I am talking to. The file covers anything on that host, including timers that run when no session is open. The label sits on the tracker, so setting it needs no access to any host, and every host that runs the check reads it. While paused, agents can still read, inspect state and prepare work. Resuming takes an explicit instruction from me; removing the file or the label clears the check, but the agents wait for the instruction.

The file and the label are read by one short script. Below are a generic version you can copy, the wrapper that makes a missing script stop the run, and the places it is called.

The check

Save this as scripts/pause-check.sh and change the path, tracker URL, issue number and label to yours:

#!/usr/bin/env bash
# pause-check.sh: run before every outward action.
# Exit 0 and print "clear" = go. Exit 75 = paused. Any other exit = the check failed.
set -euo pipefail

PAUSE_FILE="$HOME/.config/myproject/PAUSE"
LABEL="agent:paused"
ISSUE_URL="https://git.example.com/api/v1/repos/team/project/issues/42/labels"

if [[ -e "$PAUSE_FILE" ]]; then
  echo "PAUSED: $PAUSE_FILE exists" >&2
  exit 75
fi

# Read the token from a private file; never put it on the command line.
TOKEN=$(<"$HOME/.config/myproject/tracker-token")
labels=$(curl -fsS --config /dev/fd/3 "$ISSUE_URL" \
  3<<<"header = \"Authorization: token $TOKEN\"" | jq -r '.[].name')

if grep -qx "$LABEL" <<<"$labels"; then
  echo "PAUSED: label $LABEL on issue 42" >&2
  exit 75
fi
echo "clear"

A deliberate pause exits with 75, the conventional "temporary failure" code. Anything else that goes wrong, such as a missing token file or a tracker that does not answer, makes the script exit with another non-zero code before it prints clear, because of set -euo pipefail and curl -f. Callers treat both the same way: no clear, no action.

On 7 November at 2:37 in the morning (EST), our tracker returned a 502. The check failed and the publishing runner logged paused; stopping. Nothing was due at that hour. On the next run fifteen minutes later the tracker answered, the check printed clear, and the runner carried on. An unreachable tracker cost one cycle, and a pause set on that tracker could not be skipped while it was down.

Failing closed when the check is missing

The runner that publishes our social posts used to call the check like this:

pause_check=${SOCIAL_PAUSE_CHECK:-/home/me/old-checkout/pause-check.sh}

if [[ -x "$pause_check" ]] && ! "$pause_check" >/dev/null; then
  echo "paused; stopping" >&2; exit 75
fi

The default pointed at a copy of the script in the repository's old location. We had moved the repository, and the old checkout had not been deleted yet, so the copy still ran and still worked. While correcting the path, I read the condition: it pauses only if the script exists and reports a pause. If the file is missing, the condition is false and the runner goes ahead. Once someone cleaned up the old checkout, the runner would have kept publishing without consulting any pause at all, and nothing would have said so.

The fix went in as its own commit. The default points at the maintained script, the timers set the path explicitly, and a missing script stops the run:

pause_check=${SOCIAL_PAUSE_CHECK:-/srv/workspace/autonomy/pause-check.sh}

if [[ ! -x "$pause_check" ]]; then
  echo "pause check missing: $pause_check; stopping" >&2; exit 75
fi
if ! "$pause_check" >/dev/null; then
  echo "paused; stopping" >&2; exit 75
fi

For your own scripts, put that logic in one wrapper and run outward commands through it. Save this as scripts/with-pause-check.sh:

#!/usr/bin/env bash
# with-pause-check.sh CMD...: run CMD only if the pause check prints "clear".
set -uo pipefail
check=${PAUSE_CHECK:-"$(dirname "$0")/pause-check.sh"}
if [[ ! -x "$check" ]]; then
  echo "pause check missing: $check; stopping" >&2; exit 75
fi
result=$("$check") || { echo "paused or check failed; stopping" >&2; exit 75; }
[[ "$result" == "clear" ]] || { echo "unexpected check output; stopping" >&2; exit 75; }
exec "$@"

scripts/with-pause-check.sh git push origin main pushes only when the check is present and clear.

Where the check runs

A check at the start of a job is not enough when the job does several outward things in a row. Our campaign runner publishes one post per pass and runs the check before each pass, so a pause in the middle of a five-post thread lets the posts already out stay out and stops the next one from starting. A pause cannot recall something a service has already accepted; the agent reports what went out before the stop.

The timers that drive the social runner name the check explicitly:

[Service]
Type=oneshot
Environment=SOCIAL_PAUSE_CHECK=/srv/workspace/autonomy/pause-check.sh
ExecStart=/srv/strategy/social-publisher/scripts/run-standing-campaigns.sh campaign-name

Interactive agent sessions get the same requirement as a standing rule: run the check before every outward action, and stop on anything other than clear.

Building it with AIWG

Make the check a rule your assistant follows in every session. A project-local extension with a rule starter is the shortest route; ask your assistant:

Scaffold a project-local extension called stop-controls with a rule starter. The rule: before any push, tag, publish, post, send or tracker write, run scripts/pause-check.sh, and stop if it is missing or prints anything other than clear.

That runs aiwg new-bundle stop-controls --type extension --starter rule. Edit the rule file, then deploy it with aiwg use stop-controls:

---
activation: summary
summary: Run scripts/pause-check.sh before every outward action; stop unless it prints clear.
---

# pause-before-outward-action

Before any push, tag, publish, post, send or tracker write, run
scripts/pause-check.sh. If it is missing, exits non-zero or prints anything
other than `clear`, stop and report. Resume only on the owner's explicit
instruction.

A rule shapes what the assistant does in a conversation. It does not stop a scheduled job, so put the check in the scheduler too. For a scheduled AIWG job, aiwg job render-cron jobs/publish.yaml --format systemd prints a service and a timer. Add the check to the service as ExecStartPre; systemd does not run ExecStart when ExecStartPre fails:

[Service]
Type=oneshot
ExecStartPre=/srv/myproject/scripts/pause-check.sh
ExecStart=/bin/sh -lc 'aiwg job run /srv/myproject/jobs/publish.yaml --once'

A paused run then shows up as a failed unit, which an existing failed-unit alert will report. AIWG jobs also have a narrower stop built in: a job acts only on work items that carry its approval label, which is required by default. Removing approved-for-publish from one item holds that item, while the pause file holds everything.

Rehearsing it

Test each path with a command that does nothing outward:

# 1. The file
touch ~/.config/myproject/PAUSE
scripts/with-pause-check.sh echo "would act"; echo "exit $?"    # paused ... exit 75
rm ~/.config/myproject/PAUSE

# 2. The label: add agent:paused to issue 42, run the same line (exit 75), remove the label.

# 3. A missing check
PAUSE_CHECK=/nonexistent/pause-check.sh scripts/with-pause-check.sh echo "would act"; echo "exit $?"
# pause check missing ... exit 75

# 4. Clear
scripts/with-pause-check.sh echo "would act"                      # would act

The third test is the case that the old runner condition got wrong. With the old condition, it prints "would act".