Paperclip Agents Rebuilding My Portfolio: The DAR Loop and the LIKE/REJECT Gate
Four Paperclip agents took a Next.js portfolio from idea to merged pull request while I only reviewed. The interesting part is not the code — it is the DAR issue queue, the visual approval gate that stopped a broken redesign from shipping, and the two failure modes I had to design around.
I rebuilt my portfolio site with a fleet of agents and I did not write most of the code. Agents writing React is not news. The part I had to design is the loop that proposes the work, the gate where I approve what I see without reading every diff, and the two places where both of those failed on me.
Below is the actual system, failure modes included: two of them cost me a full day each.
The Unit of Work Is a DAR, Not a Prompt
The first version let me prompt agents directly. It failed immediately. Agents given a free-form goal produce an unbounded diff, and reviewing a 4,000-line diff you did not write is worse than writing it yourself.
I switched to a fixed queue: DAR, short for Do, Approve, Reject. Every unit of work is a GitLab issue with a strict frontmatter contract, and the agent’s loop is a small state machine over that contract.
┌──────────────────────────────────────────────────────────┐
│ Paperclip Gateway · 127.0.0.1:8787 · single writer │
├──────────────────────────────────────────────────────────┤
│ 1. PULL claim next open DAR with `agent:ready` label │
│ 2. BRANCH dar/<id>-<slug> from main │
│ 3. WORK edit files, run `pnpm lint && pnpm build` │
│ 4. PUSH + open Draft MR (no auto-merge, ever) │
│ 5. EVIDENCE attach screenshot diff + build log │
│ 6. STATE dar/state = needs_review · loop │
└──────────────────────────────────────────────────────────┘
The DAR issue body is the whole contract. Anything not expressible in this template does not get scheduled.
id: DAR-114
title: "Add skeleton loaders to blog index"
scope: src/components/blog/*
out_of_scope:
- src/content/** # content is human-owned
- src/lib/content.ts # schema is human-owned
acceptance:
- "pnpm build exits 0"
- "No layout shift: CLS stays under 0.02 on /blog"
visual: true # requires a LIKE/REJECT gate
risk: low # low | medium | high
agent: ready
Three fields do all the work:
scope/out_of_scope— a glob allowlist and denylist. The agent’s edits are checked against these before the commit. A file outside scope fails the run, it doesn’t get “fixed” by an agent that decided to widen its own mandate.acceptance: machine-checkable conditions only. No “make it feel nicer”.visual: the flag that decides whether a human sees a picture or reads a diff.
The LIKE/REJECT Gate
Two-thirds of this site is visual. A refactor that satisfies every acceptance criterion and still renders a broken layout is a refactor I have to catch with my eyes. So visual DARs get a different gate than code DARs.
For visual: true DARs, the agent runs the branch locally, captures screenshots at three viewports, and attaches them to the MR as a before/after strip:
dar-114-compare.png
┌────────────────────┬────────────────────┬────────────────────┐
│ BEFORE /blog │ AFTER /blog │ AFTER /blog │
│ │ │ │
│ [cards stacked] │ [skeleton rows] │ [skeleton rows] │
│ CLS 0.31 │ CLS 0.01 ✓ │ (375px) ✓ │
└────────────────────┴────────────────────┴────────────────────┘
The gate itself is deliberately tiny:
# gates/visual_gate.py: the only thing an agent must satisfy to reach a human
def evaluate(shot, dar) -> GateResult:
fail = []
# 1. The page must actually render something. A blank page passes a
# screenshot diff, which is exactly why the pixel check comes first.
non_bg = count_distinct_colors(shot.pixels, min_coverage=0.02)
if non_bg < 3:
fail.append("page appears blank (background only)")
# 2. Layout shift budget, measured by the agent's own instrumentation
if shot.cls > 0.02:
fail.append(f"CLS {shot.cls:.3f} exceeds 0.02 budget")
# 3. No horizontal overflow at the narrow viewport
if shot.scroll_width > shot.viewport_width:
fail.append(f"horizontal overflow: {shot.scroll_width}px > {shot.viewport_width}px")
# 4. Content parity: the new view must contain the same post titles
if set(dar.expected_strings) - set(shot.visible_text):
fail.append("visible content lost during redesign")
return GateResult(ok=not fail, failures=fail)
The loop above as built: dar/<id>-<slug> branches, Draft MRs only, visual:true DARs need a LIKE on the 3-viewport strip before merge.
The fourth check is the one that has saved me most often. Twice, a “visual improvement” DAR quietly dropped three blog cards from the index because the new grid’s slice(0, 6) hid the pagination. No test covered it. No acceptance criterion mentioned it. A screenshot diff would have shown “cards, but fewer cards” and I would probably have approved it. A string set comparison caught it automatically.
The gate never approves anything. It only rejects. Every passing visual DAR still lands in my queue as a LIKE or REJECT decision, because I am not going to trust a green checkmark with a screenshot to tell me whether a page looks good. The gate removes the stupid rejections so my attention goes to the judgement calls.
╭──────────────────────────────────────────────────────────╮
│ Queue · visual DARs awaiting decision │
├──────────────────────────────────────────────────────────┤
│ DAR-114 skeleton loaders [before|after] LIKE ◂ │
│ DAR-118 blog card grid revamp [before|after] REJECT ◂│
│ DAR-121 nav contrast pass [before|after] LIKE ◂ │
╰──────────────────────────────────────────────────────────╯
Failure Mode One: The Agent That Widened Its Own Scope
An agent working on the blog index noticed that the post dates were rendered inconsistently across two formats, depending on which component rendered them. It fixed both. Nobody asked it to.
This is correct behaviour by a reasonable agent and unacceptable in a pipeline. The fix is mechanical: verify the diff’s file set against scope before the push, and fail the run loudly rather than letting the change through.
# scripts/check-scope.sh: runs as the first CI stage on every agent branch
changed=$(git diff --name-only origin/main...HEAD)
while IFS= read -r f; do
case "$f" in
src/components/blog/*) ;; # in scope
src/content/*|src/lib/content.ts)
echo "::error::agent edited human-owned path: $f"
echo "::error::file a follow-up DAR instead"
exit 1 ;;
*) echo "::error::out-of-scope edit: $f"; exit 1 ;;
esac
done <<< "$changed"
The follow-up DAR was filed automatically from the failure output. That agent got 11 out-of-scope fixes caught this way over three weeks, and every one of them became a real, separate, reviewed change. The scope check is the highest-value three lines in the whole pipeline. Eleven catches. Zero debates.
Failure Mode Two: The Green Build That Broke Production
Two agents landed on different branches within an hour of each other. Each built clean in isolation. Merged together, the build failed — not a code conflict, a genuine incompatibility where the newer version of a shared hook removed an export the older one still called.
Branch-level CI cannot see this. It is a merge-order bug and it is invisible until the merge commit exists.
The fix was to make the merge itself the test:
# .gitlab-ci.yml: agent branches get an ephemeral preview before merge
preview:preview:
stage: preview
script:
- npm ci
- npx next build
- npx wrangler pages deploy .next --branch=$CI_COMMIT_REF_NAME
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
environment:
name: preview/$CI_COMMIT_REF_SLUG
url: https://$CI_COMMIT_REF_SLUG.pages.dev
on_stop: preview:stop
Every agent MR gets a real deployment on a branch-scoped URL. The visual gate screenshots that URL, not a local server, which means the gate is also testing the deploy pipeline. When the broken merge happened, the preview build caught it and the MR was marked failed before I opened it. That day cost me zero minutes.
I have not found a way to make agents not break merges. I have found a way to make the breakage show up on a URL instead of in production, which is close enough.
Where the Human Actually Spends Time
Before this system, my review time went roughly: 60% reading diffs, 25% running the thing, 15% deciding whether I liked it.
Now:
| Stage | Before | After |
|---|---|---|
| Reading diffs | 60% | 12% |
| Running / verifying | 25% | 18% |
| Visual judgement | 15% | 55% |
| Writing the DAR myself | — | 15% |
The system moved my attention to the only part it cannot do, which is deciding whether a page looks right. That is the correct division of labour, and it only works because the gates are written to reject rather than approve. An agent that can green-light my design decisions is a liability. One that reliably catches overflow, blank pages, CLS regressions, and lost content is a tool.
What I Stopped Fighting
Two of the four agents produce code I would have written differently. Not worse. Differently. The typography scale on the blog index is now a rigid modular scale with no exceptions; I would have left a couple of one-offs in. That is not a bug, and I stopped treating it as one.
What I would not give up: every line of that code went through a build, a scope check, a preview deploy, a visual gate, and a human LIKE before it existed on the main branch. The design taste is a minor loss. Never shipping unreviewed generated code is not.
If you want the same thing, the ordering matters. Start with the issue template. Start with the scope check. The prompt language is the part you iterate on last and it matters the least.
The Gate Is the Product
Developers letting agents write production code should care. Without the DAR contract and the reject-only visual gate, the failure mode is an unbounded 4,000-line diff that passes every check and still drops content: the slice(0, 6) pagination loss this post describes, which no test covered and no screenshot diff would have caught. The scope check is the highest-leverage piece. Out-of-scope edits fail loudly instead of shipping quietly, and every caught widening becomes its own reviewed change, backed by the same checkpoint-and-isolate instinct I run in the OpenClaw nerve center with CI gates shaped by the slim-CI rebuild.
Related logs
OpenClaw as an IT Operations Nerve Center: My 2026 Setup
How I replaced a patchwork of cron jobs, scripts, and notifications with a single autonomous AI agent that handles infrastructure monitoring, code deployments, and operational workflows — running 24/7 on a MacBook Air.
Grounding the Ask Box: An 8 KB Fact Blob, a Cite-Slugs Rule, and the Guards Around It
The site's ask box answers from a build-time JSON blob capped at 8 KB — titles, slugs, summaries, tags, metrics, nothing else. Here is how scripts/generate-chat-facts.mjs builds it, the cite-slugs rule in src/pages/api/chat.ts, the rate-limit and body guards, and what none of that actually proves.