Over several days, I built a centralized WordPress backup system by working with AI one verified change at a time. The AI wrote a great deal of Bash, Python, PHP and JavaScript, but it never independently controlled the production server. I inspected the evidence, ran each bounded operation, questioned incorrect diagnoses and decided when a procedure was finally reliable enough to automate.
This was neither ordinary manual coding nor fully agentic development
When people discuss AI-assisted programming, they often imagine two extremes. At one end, AI behaves like an advanced autocomplete system: the developer remains responsible for almost every decision and accepts occasional suggestions. At the other end, an agent receives a broad goal, opens the repository, modifies files, runs commands, repairs failures and continues until it considers the task complete. My workflow sat somewhere between these models, although “half-automated” does not quite describe the division of labour. The AI sometimes generated almost the entire implementation of a feature, yet I still controlled the transitions between diagnosis, patching, deployment and acceptance.
The practical boundary was simple. The conversational AI did not have persistent shell access to my production VPS. It knew only what I described or pasted into the conversation. When it needed more evidence, it proposed a read-only audit. I ran that audit, inspected the result and returned the output. When it proposed a correction, I received a complete command that created a checkpoint, constructed a candidate, validated it and—when appropriate—installed it. The actual server then answered with its own evidence. Sometimes it agreed with our explanation; sometimes it replied with an exit code and no concern for our feelings. (The server, as usual, was not emotionally invested in my confidence.)
This created a recurring development rhythm. I would begin with a goal such as renaming a repository, centralizing four backup configurations, improving diagnostics in WordPress or scheduling daily backups. AI would translate that goal into code and technical hypotheses. The system would reveal details that neither of us had fully anticipated. Well, then we would adjust the design through another bounded iteration. The project grew through many small state transitions, each visible enough to inspect and discuss.
Current descriptions of agentic AI generally emphasize independent workflow management: an agent plans, selects tools, takes actions, observes results and adapts across multiple turns. My method used some of the same reasoning capabilities, but the control structure remained human-governed. The AI could plan beyond the immediate command, while I decided whether the next step should occur. This difference mattered because the intermediate process contained much of the engineering knowledge I was developing.
The project that made this method visible
The immediate task was a backup system for four independent WordPress installations on a small VPS. The server ran Debian 13 with a 6.12 cloud kernel, one virtual CPU and approximately 1 GiB of memory. Its root filesystem was only about 9 GiB, and free space during the later work hovered around 1.8 GiB. The software stack included WordPress 7.0.4, PHP 8.4, MariaDB 11.8, Nginx 1.26, WP-CLI 2.12, Git 2.47, GitHub CLI 2.97, Python 3.13 and Cloudflare WARP 2026.6.
The four websites were separate WordPress installations, not a WordPress Multisite network. Each needed its own private GitHub repository, database export, website snapshot, recovery metadata, status file and log directory. A single backup could temporarily consume hundreds of megabytes, so simultaneous jobs were unacceptable on a server with one CPU and limited disk space. GitHub connectivity introduced another constraint: the VPS had dependable native IPv6, while the required GitHub path still needed IPv4. WARP therefore supplied temporary IPv4 transport during remote operations, with native IPv6 deliberately excluded from the tunnel so that the existing SSH connection remained independent.
The completed structure looked approximately like this:
/usr/local/sbin/example-wordpress-backup
/usr/local/sbin/example-wordpress-backup-control
/usr/local/sbin/example-wordpress-backup-all
/usr/local/sbin/example-wordpress-backup-schedule-control
/etc/example-wordpress-backup/
├── schedule.json
├── docs/
│ └── BACKUP-SYSTEM-HISTORY.md
└── sites/
├── site-main.conf
├── site-a.conf
├── site-b.conf
└── site-c.conf
/var/lib/example-wordpress-backup/
├── site-main/
│ ├── jobs/
│ └── status.json
├── site-a/
├── site-b/
└── site-c/
/var/log/example-wordpress-backup/
├── site-main/
├── site-a/
├── site-b/
└── site-c/
One generic root-owned engine loaded a protected site configuration and performed the same validated procedure for each installation. A restricted controller exposed only approved actions and site identifiers. A systemd service template created a separate service instance for each site. One WordPress plugin, active only on the main site, displayed the status of all four websites and allowed an administrator to launch a backup. Later, a systemd timer ran the four jobs sequentially each night.
A site configuration contained only the values that genuinely varied:
SITE_ID='site-main' SITE_LABEL='Main WordPress Site' WP='/var/www/example-site' REPO='example-owner/example-site-wordpress-backup' BRANCH='main' STATE='/var/lib/example-wordpress-backup/site-main' LOG_DIR='/var/log/example-wordpress-backup/site-main' DOCUMENTATION_FILE='/etc/example-wordpress-backup/docs/BACKUP-SYSTEM-HISTORY.md'
This architecture provides useful context, but the more interesting subject is how it emerged. I did not begin with a complete four-site engine, dashboard, scheduler and network safety design. The project began with one manually tested backup. Once that worked, I added a WordPress interface. The repository was then renamed, the engine became multi-site, the dashboard became centralized, three additional repositories were initialized, Unicode failures were corrected, fuller operational logs were exposed and automatic scheduling was installed. Each stage reused the previous working system instead of replacing it with a fresh design.
That continuity became one of my strongest requirements. A new feature had to extend the system already running on the VPS. Creating a second repository or writing a parallel plugin might have looked cleaner in isolation, but it would also have created two sources of truth. In practical administration, two sources of truth usually become three surprisingly quickly, and then nobody remembers which one has the latest fix.
The workflow began with evidence, not with editing
Whenever something failed, the first useful task was to determine what had actually happened. This sounds obvious, but AI can generate a plausible correction very quickly, sometimes before the relevant state has been established. I found that the quality of the patch depended heavily on the quality of the preceding audit. If the installed file, current service result, saved status and latest log were not compared, the conversation could easily solve yesterday’s problem or patch a version that no longer existed.
A focused audit of an operational script might begin like this:
FILE="/usr/local/sbin/example-wordpress-backup" test -f "$FILE" file "$FILE" stat -c '%n | %s bytes | %U:%G | %a | %y' "$FILE" sha256sum "$FILE" bash -n "$FILE" grep -nF 'relevant source anchor' "$FILE" || true
For a failed service, I also needed systemd’s view of reality:
SERVICE="[email protected]" systemctl show "$SERVICE" \ --property=LoadState \ --property=ActiveState \ --property=SubState \ --property=Result \ --property=ExecMainCode \ --property=ExecMainStatus journalctl \ -u "$SERVICE" \ --no-pager \ -n 80
The saved application status and the systemd result were intentionally treated as different sources. A previous successful backup might still be recorded in status.json even after a later preflight failed before creating a new snapshot. The dashboard originally combined these states badly: it displayed the old success message next to a red “failed” badge and removed the previous commit link. That was confusing because the service had indeed failed, but the last verified backup had not suddenly evaporated.
This was one of many moments when data modelling mattered more than visual styling. A field named commit could mean “the commit currently being constructed,” “the commit written locally,” “the commit most recently pushed,” or “the latest remotely verified successful backup.” Those meanings are not interchangeable. The final controller exposed a commit as successful only after remote verification, while transient local hashes remained part of the protected operational log.
The same discipline applied to negative requirements. A documentation correction might be authorized to change one Markdown file and push one documentation-only commit. That request did not implicitly authorize a database export, maintenance mode, new backup snapshot or unrelated service restart. Stating these exclusions at the beginning limited the blast radius and made later verification more precise. It also prevented an AI-generated script from surrounding a small edit with a grand tour of the entire server.
Define the blast radius before writing the patch
A good correction begins by saying what it is allowed to change. When I wanted to repair the mode of one documentation file, the operation did not need to export SQL, start WARP, enter WordPress maintenance mode or touch the other three sites. When I wanted to rename a repository, the operation needed remote access but explicitly prohibited a new backup or force-push. These restrictions became part of each command’s opening report so that I could see its authority before execution.
=== Correct protected documentation metadata === Only one protected documentation file may change. No backup, SQL export, maintenance mode or repository push will run.
This practice exposed over-designed commands. During some iterations, a small patch inherited every safety check used anywhere in the project. The script would inspect all websites, all services, SSH continuity, IPv6, WARP, GitHub authentication, disk space and scheduler state before changing a single local file. The intention was admirable, but the result created new ways to fail. A documentation update once stopped because the SSH-session detector interpreted the wrong fields from ss. The documentation operation itself was harmless; its ceremonial security procession had tripped over its own robes. (This may be the closest system administration comes to ecclesiastical comedy.)
I gradually learned to separate comprehensive audits from task-local safeguards. A network change deserves route, IPv4, IPv6 and SSH checks. A repository operation deserves privacy, ancestry and remote-reference checks. A metadata correction needs source identity, content preservation, the exact expected owner and mode, and a real application preflight. Safety improves when each check has a clear causal relationship with the authorized action.
This also made the commands easier to understand. A monolithic script can contain many individually sensible operations while remaining difficult to review as a whole. If it fails near the end, the operator must determine which earlier mutations happened and which did not. A focused transaction tells a clearer story: this is the current state, this is the only permitted change, this is the checkpoint, this is the candidate, and these are the tests that determine acceptance.
Checkpoint first, patch second
Before changing an installed source, I created a timestamped checkpoint outside the temporary candidate directory. For a single file, shutil.copy2 preserved its content and metadata. Multi-file changes saved the controller, engine, service units, sudo policy, canonical plugin and deployed plugin together. The checkpoint often included a rollback script so that recovery would not depend on remembering the correct modes or destinations later.
from datetime import datetime, timezone
from pathlib import Path
import shutil
source = Path("/usr/local/sbin/example-wordpress-backup")
stamp = datetime.now(
timezone.utc
).strftime("%Y%m%dT%H%M%SZ")
checkpoint = (
Path("/root")
/ f"example-backup-checkpoint-{stamp}"
/ source.relative_to("/")
)
checkpoint.parent.mkdir(
parents=True,
exist_ok=True,
)
shutil.copy2(source, checkpoint)
print(f"Checkpoint created: {checkpoint}")
A checkpoint does more than guard against disaster. It makes experimentation intellectually manageable. I can authorize a narrow hypothesis knowing that the previous working artifact remains available. It also forces the patch to reveal its scope. If restoring the previous state would require reconstructing several undocumented relationships, the proposed change is probably broader than it first appeared. A rollback plan is a little like an umbrella: mildly inconvenient until the precise minute it becomes your closest friend.
For coordinated files, the rollback logic was explicit:
install \
-o root \
-g root \
-m 0750 \
"$CHECKPOINT/usr/local/sbin/example-controller" \
"/usr/local/sbin/example-controller"
install \
-o root \
-g root \
-m 0644 \
"$CHECKPOINT/etc/systemd/system/[email protected]" \
"/etc/systemd/system/[email protected]"
systemctl daemon-reload
I rarely needed to run these rollback scripts, but their existence changed the quality of the deployment decision. “This should work” is less reassuring than “this candidate passed the relevant tests, and here is the exact recovery path if the live integration still rejects it.” Honestly, the second sentence also helps one sleep better after editing a root-owned service late at night.
Why I often used Python for find-and-replace
A large part of the workflow involved modifying existing files without asking me to open an editor and manually hunt for the relevant block. Python’s pathlib, shutil and regular-expression support made these transformations deterministic. The script could copy the source, check the expected anchor, apply one replacement, write a candidate and refuse to continue when the installed version differed from the assumed source.
For a stable block that should occur exactly once, literal replacement was often the clearest option:
from pathlib import Path
candidate = Path(
"/var/tmp/example-fix/example-controller"
)
text = candidate.read_text(encoding="utf-8")
old = """old exact block
with the original indentation
and enough surrounding context
"""
new = """corrected block
with the intended indentation
and enough surrounding context
"""
count = text.count(old)
if count != 1:
raise SystemExit(
f"Expected one source block; found {count}."
)
candidate.write_text(
text.replace(old, new, 1),
encoding="utf-8",
)
The count assertion is one of the smallest yet most valuable safeguards in the method. If the result is zero, the patch was written for a different source. If it is greater than one, the anchor is ambiguous. Both cases stop before deployment. Without that assertion, a patch can run successfully while changing nothing, or it can modify several unrelated locations and still exit with status zero. Computers are extremely obedient in this respect: they will perform the wrong replacement with admirable punctuality.
Regular expressions helped when the content inside a block varied but its boundaries remained stable. I preferred markers or surrounding function signatures that described the semantic region, then used subn to retain the replacement count:
from pathlib import Path
import re
candidate = Path(
"/var/tmp/example-fix/example-controller"
)
text = candidate.read_text(encoding="utf-8")
pattern = re.compile(
r"(?ms)^# BEGIN STATUS LOGIC$"
r".*?"
r"^# END STATUS LOGIC$"
)
replacement = """# BEGIN STATUS LOGIC
corrected status implementation
# END STATUS LOGIC"""
updated, count = pattern.subn(
replacement,
text,
)
if count != 1:
raise SystemExit(
f"Expected one status block; found {count}."
)
candidate.write_text(
updated,
encoding="utf-8",
)
The repository rename showed why more careful boundaries were necessary. The old slug appeared as a bare name, inside owner/repository, inside an HTTPS URL and inside generated status data. A naive replacement could add the new prefix twice or alter part of a longer identifier. The corrected transformation treated the slug as a token and then counted old, new and doubled references in every candidate. The operation was structurally valid only when all expected old references had disappeared and the known number of new references remained.
Text replacement was not the automatic answer to every file. JSON was parsed into an object, modified and serialized. Filesystem metadata was changed with install, chown or chmod. Complex source refactoring may call for an abstract syntax tree. What made find-and-replace appropriate in many of these corrections was the combination of a unique anchor, a small intended diff and a clear failure condition. Used this way, it becomes an auditable patching technique, not a blind search box with root privileges—which, uhh, is not a product I would be eager to beta-test.
Build and validate the candidate before touching production
The live source was copied into a private temporary directory and modified there. This candidate could fail syntax checks, structural checks or regression tests without affecting the installed system. I could inspect the diff between the installed file and the candidate before authorizing deployment. This separation became one of the clearest expressions of human control in the workflow: AI generated the transformation, deterministic tools tested it, and I decided whether the verified candidate should cross into production.
SOURCE="/usr/local/sbin/example-wordpress-backup"
WORK="$(mktemp -d /var/tmp/example-patch.XXXXXX)"
CANDIDATE="$WORK/example-wordpress-backup"
cp --preserve=all \
"$SOURCE" \
"$CANDIDATE"
# Python modifies $CANDIDATE here.
bash -n "$CANDIDATE"
diff -u \
"$SOURCE" \
"$CANDIDATE" \
|| true
The diff mattered because an AI explanation describes intention, while the diff displays effect. These are not always identical. A generated patch may insert the right block in the wrong function, remove adjacent comments or match a second region that looked similar in the conversational excerpt. A small diff lets me evaluate the actual intervention without rereading a thirty-thousand-byte script from the beginning.
After validation, deployment used explicit ownership and mode:
install \
-o root \
-g root \
-m 0750 \
"$CANDIDATE" \
"$SOURCE"
test "$(
sha256sum "$CANDIDATE" |
awk '{print $1}'
)" = "$(
sha256sum "$SOURCE" |
awk '{print $1}'
)"
The checksum comparison confirmed that the installed file was exactly the candidate that had passed validation. This closes a subtle gap between “the candidate was good” and “the good candidate is what production received.” In a casual local project, I might consider that distinction unnecessary. On a small production VPS containing several websites and their databases, I preferred the checksum to optimism.
A validator can be wrong too
The repository-renaming work produced one of the most revealing early failures. The controller candidate was sent to Python’s compiler, and Python reported:
File ".../example-wordpress-backup-control", line 19
start)
^
SyntaxError: unmatched ')'
The line was part of a Bash case statement. The controller was a valid shell script, but the validation process had treated it as Python. Because the error contained a filename, line number and familiar word such as SyntaxError, it initially looked authoritative. In reality, Python had successfully proved that Bash is not Python. Philosophically interesting, perhaps; operationally, less so.
The corrected validation selected a parser for each artifact:
bash -n candidate-backup
bash -n candidate-controller
php -l candidate-plugin.php
node --check extracted-plugin-script.js
systemd-analyze verify \
[email protected]
visudo -cf candidate-sudoers
python3 -m json.tool \
candidate-status.json \
>/dev/null
This incident changed how I thought about verification. A check is not automatically useful because it is strict or produces detailed output. It must correspond to the artifact and the property being claimed. bash -n establishes shell syntax, but it cannot prove that a repository exists. systemd-analyze verify inspects unit structure, but it may complain that a referenced executable is absent if the candidate has not yet been installed. A GitHub API response establishes repository privacy, but it cannot prove that the WordPress database export is complete.
Validation therefore became a layered argument. Source checks established that the patch targeted the audited version. Syntax checks confirmed that the relevant parser accepted the candidate. Structural tests counted repository references, configuration keys or staged paths. Regression fixtures tested known edge cases. Deployment checks compared candidate and installed checksums. Runtime preflights demonstrated that the application accepted the new state. Final safety checks confirmed that maintenance mode, temporary files and network authorization had been removed.
From Chinese filenames to NUL-safe Git processing
The first backup of one additional site failed during “metadata and direct Git capture.” The SQL export had succeeded, maintenance mode was cleaned up and temporary material was removed, but the dashboard initially showed too little detail to locate the exact problem. After safer engine diagnostics were added, the next run reported line information and exit code 141. The site contained Chinese filenames, which became an important clue.
The original validator used newline-delimited output from git ls-files and passed it through awk to confirm that every staged path belonged to an expected top-level directory. Git may quote unusual names in human-readable output. More importantly, a downstream command that exits after finding the first unexpected line can close the pipe before Git finishes writing. Git then receives SIGPIPE, and the pipeline can fail with exit code 141 even though the repository data is valid.
The replacement used raw NUL-delimited paths and consumed the complete input before evaluating it:
git ls-files -z |
python3 -c '
import sys
paths = sys.stdin.buffer.read().split(b"\0")
allowed = {
b"website",
b"database",
b"restore",
b"docs",
b"README.md",
b".gitattributes",
}
for path in paths:
if not path:
continue
top = path.split(b"/", 1)[0]
if top not in allowed:
raise SystemExit(
"Unexpected top-level staged path."
)
'
The regression test created a small real Git repository containing ordinary names, spaces and Unicode filenames, then verified both accepted and rejected top-level paths. The next production backup succeeded, capturing more than ten thousand entries and pushing the initial branch to its private repository.
What I like about this correction is that it did not create a special “Chinese mode.” The revised code addressed the actual abstraction: Unix filenames are byte sequences that must not be assumed to use newline as a safe separator. The filename had been innocent all along; the newline assumption was the actual criminal. The result supports Chinese, spaces, quotation-sensitive names and other Unicode scripts without needing to know which languages future filenames may contain.
Direct Git capture on a small server
The VPS did not have enough free space for a comfortable full copy of every website plus an SQL dump and a second Git working tree. The backup engine therefore created a temporary bare Git object store and used alternate indexes. Website files were hashed directly from the live WordPress root into Git objects, while generated recovery files were staged from a separate temporary directory.
The website index was populated with environment variables that separated the Git database, work tree and index:
GIT_DIR="$GITDIR" \
GIT_WORK_TREE="$WP" \
GIT_INDEX_FILE="$SITE_INDEX" \
git add -f -A -- .
The resulting site tree could then be inserted under the website/ prefix of the main backup tree:
GIT_DIR="$GITDIR" \
GIT_INDEX_FILE="$MAIN_INDEX" \
git read-tree --empty
GIT_DIR="$GITDIR" \
GIT_INDEX_FILE="$MAIN_INDEX" \
git read-tree \
--prefix=website/ \
"$SITE_TREE"
The generated area contained the SQL export, restoration scripts, checksums, permissions, ownership, directory and symlink manifests, server-version records, plugin and theme inventories and the recovery README. These paths were added through the second index before the final tree was written.
EXTRA_GIT_PATHS=(
.gitattributes
README.md
database
restore
)
if [[ -n "$DOCUMENTATION_FILE" ]]; then
EXTRA_GIT_PATHS+=(docs)
fi
GIT_DIR="$GITDIR" \
GIT_WORK_TREE="$EXTRA" \
GIT_INDEX_FILE="$MAIN_INDEX" \
git add -f -- \
"${EXTRA_GIT_PATHS[@]}"
This design reduced temporary disk duplication while preserving a self-contained repository layout. It also introduced more validation work: the engine compared the number of Git-tracked website entries with the observed number of regular files and symlinks, checked the staged SQL checksum, enforced known top-level paths and rejected generated files above GitHub’s practical per-file limit. The complexity was justified by the server’s resource constraints, but it needed careful instrumentation because a failure inside this stage could otherwise be difficult to distinguish from an ordinary git add problem.
Recording what Git cannot preserve
Git stores file content, executable bits, paths and symlink targets, but it does not preserve every Unix ownership and permission detail or represent empty directories naturally. The backup therefore generated restoration manifests. A Python walk used lstat() so that symlinks were inspected without following them, percent-encoded arbitrary path bytes and wrote separate tab-separated records for permissions, ownership, directories, symlinks and regular files.
information = path.lstat()
if stat.S_ISDIR(information.st_mode):
kind = "directory"
elif stat.S_ISREG(information.st_mode):
kind = "file"
elif stat.S_ISLNK(information.st_mode):
kind = "symlink"
else:
raise RuntimeError(
f"Unsupported object: {relative}"
)
mode = stat.S_IMODE(
information.st_mode
)
record = (
encoded_relative,
kind,
f"{mode:04o}",
information.st_uid,
information.st_gid,
)
A restoration helper later recreated empty directories, applied ownership with os.chown() and restored non-symlink modes with os.chmod(). Website and recovery checksums made content verification independent of Git history. The repository therefore contained both the snapshot and a description of the filesystem properties needed to reconstruct it.
This part of the project illustrates why AI-generated code still required architectural judgment. It would have been easy to say “Git backs up the website” and stop there. A restorable system needed a clearer definition of what “website” included. SQL contents, WordPress files, symlink targets, empty directories, ownership, permissions, software versions and restoration order all belonged to the recovery problem, even though Git represented only some of them directly.
Temporary IPv4 without sacrificing native IPv6
The network design also became part of the iterative method. The VPS used native IPv6 for normal operation and SSH, but GitHub access required IPv4. WARP ran only during the GitHub portion of a preflight or backup. A volatile authorization marker allowed the daemon to start through a systemd gate; a rescue watchdog could stop it if the main process stalled; and ::/0 remained excluded so that IPv6 traffic did not enter the tunnel.
The engine verified the resulting split with separate address families:
IPV4_TRACE="$(
curl -4fsS \
https://www.cloudflare.com/cdn-cgi/trace
)"
IPV6_TRACE="$(
curl -6fsS \
https://www.cloudflare.com/cdn-cgi/trace
)"
grep -qx 'warp=on' \
<<<"$IPV4_TRACE"
grep -qx 'warp=off' \
<<<"$IPV6_TRACE"
Earlier audits failed for reasons that had little to do with actual connectivity. One check searched only a limited IPv6 routing view and reported that the default route was absent, even though functional IPv6 HTTPS worked. Another WARP check misinterpreted command readiness. The corrected audits examined the complete routing-table set, performed an actual IPv6 route lookup and made a functional HTTPS request. I think this is generally a better principle: when a high-level state can be tested safely through real behaviour, configuration inspection should support that test instead of becoming its substitute.
Every operation ended with a mandatory cleanup section. WARP had to be inactive, boot-disabled and without its volatile authorization marker. WordPress maintenance mode had to be off. Nginx, MariaDB and PHP-FPM had to remain active. Native IPv6 HTTPS had to work with warp=off. These checks did not prove every possible property of the server, but they directly covered the risky temporary states introduced by the backup.
The WordPress plugin remained an interface, not the privileged engine
Once the command-line backup had succeeded repeatedly, I wanted to launch it without opening an SSH session. The WordPress plugin did not reimplement the backup logic in PHP. It authenticated the administrator, checked an AJAX nonce and called a tightly restricted root-owned controller through sudo -n. The controller accepted only known actions and site IDs.
$command = [
'/usr/bin/sudo',
'-n',
'/usr/local/sbin/example-backup-control',
$action,
$site['id'],
];
$process = proc_open(
$command,
$descriptors,
$pipes,
null,
null,
['bypass_shell' => true]
);
The use of an argument array and bypass_shell avoided constructing a shell command from request text. The controller added another allowlist:
case "$REQUESTED_SITE_ID" in
site-main|site-a|site-b|site-c)
;;
*)
usage
;;
esac
case "$ACTION" in
start|status)
;;
*)
usage
;;
esac
The corresponding sudoers policy permitted the web-server user to invoke only the exact controller commands required by the dashboard. WordPress never received general root access, arbitrary repository selection or an unrestricted shell. Giving a PHP plugin unrestricted root would certainly simplify the controller, but so would leaving the front door open simplify the design of a key.
The status response acted as a contract between the root-owned system and the browser:
{
"site_id": "site-main",
"site_label": "Main Site",
"state": "verifying",
"message": "Verifying the remote commit and privacy.",
"service_active": true,
"started_at": "2026-08-13T21:28:54Z",
"database_size": "19MiB",
"captured_file_count": 12758,
"captured_size_bytes": 403265816,
"commit": "",
"maintenance_mode": false,
"temporary_material_removed": false
}
The first plugin version displayed one site. A later version used the same active plugin on the main administration site to control all four. Identical but inactive plugin copies had initially been installed on the other websites. I eventually asked why they existed if they were never activated. They were removed, leaving one canonical source and one operational deployment. This was a small architectural simplification, but it reflected an important human contribution: noticing when technically harmless duplication made the system harder to understand.
Why I insisted on fuller logs
The first dashboard log was deliberately restrictive. The controller selected only lines matching a list of safe regular expressions. That reduced the risk of exposing secrets through WordPress, but it also removed the details needed to diagnose unfamiliar failures. After a backup failed during metadata capture, I had to run another root-level audit simply to discover the engine line and exit code. The interface was safe in one sense and operationally weak in another.
I asked for the complete operational log to appear in the dashboard. The correction returned all useful lines while applying targeted protection to credentials, tokens, private keys and registration identifiers. This compromise kept the page suitable for real debugging without simply publishing every byte a root process might emit.
The distinction between selective redaction and selective inclusion matters. An allowlist displays only lines anticipated when the controller was written, so a new failure may disappear precisely because it is new. Targeted redaction begins from a fuller operational trace and removes known sensitive patterns. It requires careful review, but it preserves much more diagnostic context. The dashboard became genuinely useful once I could see push responses, engine diagnostics, cleanup details and final status without returning to SSH for every error.
Generated documentation and permanent history needed different homes
Each backup regenerated its recovery README.md with the latest timestamp, software versions, database checksum, file counts and restoration order. I initially added the project’s long manual history to that README. The next backup behaved exactly as designed and replaced it. Thirty-five kilobytes of carefully maintained context disappeared from the current tree because I had mixed generated snapshot documentation with permanent design history.
The missing text still existed in Git history, so it was recovered from a known commit and installed as docs/BACKUP-SYSTEM-HISTORY.md. A protected local copy became the authoritative source. Future backups copied that file into the repository while continuing to regenerate the snapshot README. The two documents now followed different lifecycles: one described the latest recovery state, and the other explained how the system had evolved. Git remembered the missing document, thankfully; after several hours of debugging, the human memory in the room was becoming a less dependable storage medium.
A later main-site backup failed because this protected file had mode 0644. An initial correction assumed that 0600 would satisfy the engine because it was more restrictive. The command changed the mode successfully, but the real preflight still rejected it. Inspection of the installed validation predicate revealed an exact requirement of root:root:640. After applying that mode, the same non-pushing preflight passed.
This sequence remains one of my favourite examples of why the environment must participate in the reasoning. The first correction was sensible in general security terms and wrong for the actual access contract. The authoritative source was not the AI’s intuition or mine; it was the installed predicate followed by the real preflight. Uhh, yes, sometimes the correct answer is hidden in the code that is already running. A revolutionary debugging technique.
Remote success needed its own definition
The backup engine created commits directly from Git trees. A local commit hash appeared before the push, but the dashboard could not treat it as a successful backup until GitHub accepted it and the remote branch pointed to the same object. The repository also had to remain private, and required recovery paths had to be present in the remote commit.
The verification logic compared the remote head with the fixed local commit:
REMOTE_HEAD="$(
git ls-remote \
"https://github.com/$REPO.git" \
"refs/heads/$BRANCH" |
awk '{print $1}'
)"
[[ "$REMOTE_HEAD" == "$COMMIT" ]] ||
fail "Remote branch does not match the fixed commit."
One initial push returned a GitHub Internal Server Error after the local snapshot had been built successfully. The SQL export, Git tree, file counts and maintenance cleanup were all healthy. The remote error did not justify redesigning the backup engine. A later correction added bounded push retries and ensured that unverified local commit hashes stayed hidden from the dashboard’s “latest successful commit” field. The next generated snapshot pushed successfully.
A documentation-only update revealed the opposite problem. The push output reported a successful fast-forward, but an immediate follow-up query appeared not to see the new head. The operation was marked failed even though the remote branch had moved. Verification was subsequently designed to account for short-lived read inconsistency by checking the returned ref and retrying bounded reads. A remote system can fail to accept a correct push, and a verifier can briefly fail to observe a successful one; robust status modelling must allow for both.
Turning a proven manual workflow into automatic scheduling
I postponed automatic backups until every site had its own private repository, passed the same non-pushing preflight and completed at least one remotely verified manual backup. Scheduling an immature process would have made failures happen unattended without making them easier to understand.
The generic engine already used a global lock:
exec 9>/run/lock/example-wordpress-backup.lock
if ! flock -n 9; then
echo "Another backup is already running." >&2
exit 75
fi
The sequential runner called the four site backups in a fixed order. The global lock remained authoritative, so a manual request and a scheduled sequence could not consume the server simultaneously. The systemd timer ran daily at 02:30 UTC with up to ten minutes of randomized delay:
[Unit] Description=Daily sequential WordPress backups [Timer] OnCalendar=*-*-* 02:30:00 UTC RandomizedDelaySec=10m Persistent=false Unit=example-wordpress-backup-all.service [Install] WantedBy=timers.target
The centralized WordPress dashboard then gained schedule status and controls for changing the UTC time, pausing the timer and resuming it. WordPress remained the human interface, while systemd retained responsibility for scheduling and service execution. This avoided relying on WordPress cron traffic and kept privileged operations inside the existing root-owned boundary.
The scheduler illustrates the progression from exploratory collaboration to dependable automation. The design, debugging and first runs remained closely supervised. Once the process had stable configurations, locks, logs, cleanup and remote verification, daily execution no longer needed the same level of human attention. Human agency was expressed through the decision to automate a mature workflow and through the conditions imposed on that automation.
What the final evidence looked like
By the end of the iterative work, all four sites had completed verified private backups. The sites varied significantly: one captured roughly 12,700 entries and about 384 MiB, another contained more than 10,000 entries and many Unicode media names, the smaller experimental installation captured around 216 MiB, and the fourth included more than 13,000 entries and an SQL export of approximately 20 MiB. Each repository retained its own history, generated recovery data and latest verified status.
After the documentation-mode correction, the main site completed another full backup. It exported about 19 MiB of SQL, captured 12,758 tracked entries and approximately 403 million uncompressed blob bytes, created a fixed commit, disabled maintenance mode before upload, pushed the commit as a fast-forward and verified the remote recovery artifacts and privacy. Cleanup removed roughly 250 MB of temporary job material. WARP returned to its inactive, boot-disabled state, the volatile marker disappeared and native IPv6 continued to work.
Those numbers matter because they turn the methodology into something more than a theory about how software might be developed. The patching and validation loop produced a functioning production system under tight resource and network constraints. At the same time, the successful output does not erase the failed attempts. The wrong parser, unsafe path delimiter, missing optional directory, GitHub server error, stale verifier, confusing status model and incorrect file-mode assumption all contributed to the final architecture.
Where I see human agency in this process
If agency were measured by manually written characters, the AI would appear to have done most of the work. It produced long Bash commands, Python transformations, PHP controller code, JavaScript status handling, systemd units and documentation. That measurement would miss the decisions that shaped the project. I decided that the existing repository should be renamed and preserved. I rejected duplicate repositories and a second plugin. I requested one dashboard for all sites, questioned the need for inactive plugin replicas, insisted on fuller logs, separated permanent history from generated documentation and delayed scheduling until the manual workflow was established.
Agency also appeared when I supplied context that changed the meaning of an apparent failure. A remote branch being ahead of the status commit initially looked suspicious; I knew that I had manually edited the README and could explain the difference. A large deletion in the generated README looked alarming until its lifecycle was understood. When the dashboard showed “failed” beside the message “Backup pushed, verified and cleaned,” I recognized that two historical states had been combined incorrectly. These interventions did not require me to write the underlying controller, but they required a model of what the system was supposed to mean.
The most important moments often began with a very short question: “So?”, “Why did this fail again?”, or “Can the full log appear here?” Such questions forced the technical explanation to reconnect with the actual goal. The AI could generate highly elaborate commands, but I was able to notice when the workflow had become unnecessarily complicated or when a safety check was obstructing an unrelated task. This is a form of design agency that code-volume metrics cannot capture.
The collaboration was mutual at the level of debugging because both sides adapted. I became more precise about output format, rollback expectations, privacy and scope. The AI revised its hypotheses and generated increasingly specialized validations. Responsibility remained mine. The system affected my server, websites and repositories; the AI had no independent stake in their continued operation. Human–AI collaboration can therefore be real without implying equal accountability.
Why the intermediate work may matter for education
A fully agentic coding system can compress many of these stages. It may read the file, form a hypothesis, edit the source, run tests, repair its own mistake and present a final commit. That capability is useful, especially for mature tasks with strong automatic evaluation. In an educational context, however, the compressed material often contains the learning. The student needs opportunities to encounter the original source, predict the effect of a patch, inspect the diff, see a validator reject an assumption and explain why the revised model is stronger.
The Unicode incident, for example, connected shell pipelines, Git path representation, byte processing, Unicode filenames and SIGPIPE in one real debugging problem. The documentation-mode incident connected Unix permissions, protected configuration, exact predicates and application-level validation. The repository failures distinguished local commits, remote acceptance and remote observation. These concepts became meaningful through their relationship to a functioning system.
This resembles situated learning more than a conventional sequence of isolated exercises. I did not first study every detail of alternate Git indexes, systemd templates, WordPress AJAX security and split-tunnel networking and then apply the completed knowledge. The concepts appeared as the project demanded them. AI helped make unfamiliar mechanisms accessible at the moment they became relevant, while the environment prevented plausible explanations from floating free of evidence.
That last condition is crucial. Semi-automated work is not educational merely because a person copies one command at a time. A learner can approve every operation without understanding the hypothesis, scope or result. Human agency becomes substantial when the learner can explain what state existed before the patch, what the patch was expected to change, what remained protected, how failure would be recognized and why the final evidence justified proceeding.
How I would assess this kind of work
If a course assesses only the finished plugin or repository, a deeply understood human–AI project may look identical to an artifact generated and accepted with little reflection. The development record offers richer evidence: initial constraints, read-only audits, candidate diffs, failed hypotheses, validator design, rollback checkpoints, runtime results and moments when the student redirected the AI. These materials reveal how the student understood the system and how that understanding changed.
A useful assignment could require students to select one failed intervention and reconstruct it carefully. They would describe the observed symptom, the initial explanation, the proposed patch, the expected result, the actual output and the revised model. The quality of this reconstruction would show whether the student treated AI as an oracle or as one participant in an evidence-based process.
An agency ledger could record the same pattern more compactly:
| Field | Example |
|---|---|
| Observed state | A preflight rejected a protected documentation file. |
| AI proposal | Change its mode from 0644 to 0600. |
| Human decision | Authorize a reversible metadata-only test. |
| Actual result | The real preflight still failed. |
| Revised model | The engine required a specific access contract. |
| Authoritative evidence | The installed predicate required root:root:640. |
| Final validation | The real non-pushing preflight passed. |
| General lesson | Read exact security predicates instead of inferring them from intuition. |
This approach also offers an alternative to unreliable attempts to detect whether students used AI. The relevant question is not whether assistance occurred. It is whether the student can demonstrate problem framing, causal reasoning, validation, safety and reflective control. As code generation becomes easier, these capabilities may become more important parts of software-engineering education.
How this method can lead toward agentic AI
I do not see the Patch–Verify Loop as an argument against agentic systems. It can function as a path toward them. During early development, the human stays close to the evidence because the tools, edge cases and acceptance criteria are still being discovered. Repeated successful operations can then become deterministic functions with narrow inputs, explicit permissions and machine-verifiable outcomes. An agent may eventually select among those tools while high-risk actions retain approval or containment boundaries.
The backup engine followed this route. At first, I manually ran preflight and full backup commands over SSH. Once the controller and systemd service were trusted, WordPress could start the same operation through a restricted interface. After all four sites completed successful manual backups, systemd could schedule the sequence. The level of automation increased as the surrounding evidence improved.
This suggests a useful educational progression. Students might begin by using AI to explain and audit a system, then move to bounded candidate patches, deterministic workflows and finally agentic orchestration. At the agentic stage, they would need evaluation suites, tool-level permissions, failure thresholds, state inspection and escalation rules. The earlier patching work would give those guardrails a basis in observed failure instead of abstract caution.
Agentic AI still requires verification because autonomy increases the number of state transitions that may occur before a human sees the result. An early misunderstanding can propagate across several tool calls. Candidate staging, exact preconditions, negative tests, rollback tools and environmental assertions remain valuable even when an agent executes them automatically. The semi-automated workflow can therefore act as the workshop in which trustworthy agent tools are designed.
What I would improve next
The method worked, but it also exposed its own weaknesses. Some generated commands became too large for comfortable human review. Repeating every historical safeguard made later patches slower and more fragile. The handoff document grew large enough that maintaining internal consistency became a task of its own. Full operational logs improved debugging but required careful protection against credentials and private identifiers. Copying long commands between the conversation and terminal also introduced the possibility of formatting damage.
A future version could give each patch a small manifest containing the audited source checksum, authorized targets, expected replacement counts, validators and rollback location. Read-only audit routines could become stable reusable commands instead of being regenerated inside every fix. Complex changes could run first on a disposable virtual machine containing representative WordPress files, Unicode names, unusual permissions and simulated remote errors. The production command would then be shorter because much of the regression work had already happened elsewhere.
The backup system itself still needs the kind of test that no repository snapshot can replace: a complete restoration rehearsal on a disposable VPS. The repositories contain SQL exports, WordPress files, manifests, checksums and restoration instructions, and each backup verifies their presence. A real recovery exercise would test whether those components are sufficient when starting from an empty server. A backup without a tested restoration is, well, a kind of theological claim about the future: sincere, carefully documented and still awaiting fulfilment.
What I learned from the process
The project changed my view of AI-assisted programming. I began with a practical desire to click one button in WordPress and receive a verified private backup. The final system reached that goal and expanded to four websites, centralized monitoring and automatic scheduling. The more lasting result, however, was a way of working.
I learned that small patches can preserve understanding when the system is evolving quickly. Exact anchors and replacement counts turn assumptions about source code into executable checks. Candidate directories keep generation separate from deployment. Language-specific parsers prevent impressive but irrelevant error messages. Runtime preflights reveal contracts that static inspection misses. Remote verification distinguishes a locally created object from a backup that actually exists elsewhere. Permanent documentation lets a new conversation continue without inventing the past.
I also learned that human agency does not depend on typing every line. It appears in the selection of goals, the definition of constraints, the interpretation of failures, the refusal of unnecessary complexity and the decision to automate only after a process has earned that trust. AI expanded the range of work I could undertake, especially across Bash, Python, PHP, JavaScript, systemd, Git and networking. The project remained mine because I continued to guide what the system should become and what evidence counted as success.
Agentic AI asks how much of a workflow a machine can complete independently. My experience led me to a complementary question: how can AI extend a person’s technical capacity while keeping the important intermediate decisions understandable and accountable? For unfamiliar systems, production infrastructure and education, that question may matter as much as autonomy itself.
Observe the real state, preserve what works, make the smallest justified change, verify it through an independent mechanism, and let the evidence guide the next human decision.
References
- Amershi, S. et al. Guidelines for Human-AI Interaction. CHI, 2019.
- Anthropic. Trustworthy Agents in Practice. 2026.
- Anthropic. Demystifying Evals for AI Agents. 2026.
- Brown, J. S., Collins, A. and Duguid, P. Situated Cognition and the Culture of Learning. Educational Researcher, 1989.
- Long, D. and Magerko, B. What Is AI Literacy? Competencies and Design Considerations. CHI, 2020.
- OpenAI. A Practical Guide to Building AI Agents.
- Parasuraman, R., Sheridan, T. and Wickens, C. A Model for Types and Levels of Human Interaction with Automation. IEEE Transactions on Systems, Man, and Cybernetics, 2000.
- Parasuraman, R. and Manzey, D. Complacency and Bias in Human Use of Automation. Human Factors, 2010.
- Shneiderman, B. Human-Centered Artificial Intelligence: Reliable, Safe and Trustworthy. International Journal of Human–Computer Interaction, 2020.
- UNESCO. Guidance for Generative AI in Education and Research. 2023.
