For the GitLab Transcend "Life After Code" hackathon I built Ghost Customers: three synthetic customers who walk every build, file issues in their own voice, approve AI fixes only when the fix works for them, and close their own issues once production passes.
The goal was the hardest autonomy level, hands-off: you set the intent and walk away. Getting there taught me one rule I wish I'd known on day one:
A flow can't wake a flow. GitLab Duo Agent Platform triggers only fire on human actions.
This post is about what that rule broke, and the design it pushed me into, which I now think is the right design anyway.
The loop
Three personas, each checking something real customers hit and teams usually skip:
| Persona | Who | Checks |
|---|---|---|
| Dana | RSVPs from her phone on slow 4G | RSVP works on a 375px screen, page loads in under 3 s |
| Sam | Blind, keyboard and screen reader only | Every field has an accessible name, Enter confirms |
| Priya | Organizer with an Excel guest list | A "CSV UTF-8" export from Excel imports cleanly |
On every pipeline:
- Walk. A CI job runs a deterministic Playwright journey per persona against the container image the pipeline just built, and prints a JSON report.
- Report. A Duo custom flow (triggered on pipeline passed) reads the report and opens an issue in the persona's voice: steps, evidence, a screenshot, and "done when" criteria.
- Fix. Duo Developer picks up the issue and opens a merge request with a fix and a regression test.
- Verify. The MR pipeline walks the preview build. The persona reads its own result and either approves the exact commit ("I tried this preview at commit 048aabda and it finally works") or sends it back to draft.
-
Merge, then recheck. Only a persona-approved commit with green checks merges. The persona then walks production again and labels the issue
verified-live, or reopens it.
That's the plan. Here's what the platform said.
Wall 1: the assignment that silently vanished
My first version had the persona flow assign Duo Developer to the new issue. The API call succeeded. The assignee never appeared. No error, no session.
Mentioning the Developer from the flow didn't work either. The docs explain why: every trigger event needs a human actor. A service account, a bot or another flow can't fire a trigger, so a flow cannot hand work to another flow by assigning or mentioning it.
A second consequence took longer to notice: Duo Developer's own MR pipelines are started by a bot, so they never woke my persona flow to review the fix.
Wall 2: the flow can't start sessions or merge
Next I tried doing it all from inside the flow:
-
start_flowfailed withHTTP 422: Error in creating workload: Insufficient permissions to create a new pipeline. - Merging failed with
403 insufficient_scope, because the flow's token is scoped toai_workflows.
So the agent could decide everything and do almost nothing outside issues and notes.
The design: personas decide, dispatch acts
Instead of fighting that, I split the system along that line.
- The agent judges. The persona flow reads evidence, writes as the persona, and approves or rejects a specific commit in a note. It never merges and never edits files.
-
Plain code acts.
ghost-dispatchis a small Python job that runs only in pipelines onmain. It uses a maintainer token stored as a protected, masked CI variable, so onlymaincan read it. It assigns Duo Developer to new ghost issues, merges only persona-approved commits, and asks Duo Developer to rebase conflicts.
Because dispatch acts with a human's token, its assignment counts as a human action and fires the Developer trigger. Its merge starts a new pipeline on main, which wakes the personas to recheck production. The platform rule that blocked me became the backbone of the loop.
The merge decision is deliberately boring. A fix merges only if the persona named that exact commit:
PERSONA_AUTHOR_PREFIX = "ai-ghost-customers"
REQUIRED_JOBS = ("unit-tests", "merge-gate", "ghost-walk")
def persona_verified(notes: list[dict], sha: str) -> bool:
short = sha[:8]
return any(
(note.get("author") or {}).get("username", "").startswith(PERSONA_AUTHOR_PREFIX)
and not note.get("system")
and short in (note.get("body") or "")
for note in notes
)
def pipeline_ready(pipeline: dict | None, jobs: list[dict], sha: str) -> bool:
if not pipeline or pipeline.get("sha") != sha or pipeline.get("status") != "success":
return False
status = {job["name"]: job["status"] for job in jobs}
return all(status.get(name) == "success" for name in REQUIRED_JOBS)
If Duo Developer pushes a new commit after the approval, the approval no longer matches and the persona has to walk it again. There's also a deterministic merge-gate job: a fix may only touch app/ and tests/, so no agent can "fix" a bug by editing the persona journey that found it. Guardrails cap each run at 3 issues and 3 merges.
What real runs taught me
Each of these happened in the first live runs, and each became a rule:
-
GitLab closes the issue before the customer checks. An MR saying "Fixes #3" auto-closes the issue on merge. Now personas also recheck closed issues without
verified-live, and reopen them if production still fails. - A stale walk filed a duplicate. A walk of the commit just before a merge still saw the bug and opened a duplicate issue. Now walks that predate an issue's close are ignored.
- Two fixes conflicted. Sam's fix merged first and Priya's MR conflicted in the shared test file. Dispatch asked Duo Developer to rebase (once per commit), it pushed a clean commit, Priya walked the new commit and re-approved, and it merged. Nobody touched it.
-
"Conflict" that wasn't. After the rebase,
detailed_merge_statuskept sayingconflictbecause GitLab rechecks it lazily. Dispatch now trusts onlyhas_conflicts.
Takeaways
- Draw a hard line between judgment (agents) and authority (small, auditable code with narrow permissions). It made the system safer and made the platform's limits easy to work with.
- On GitLab Duo, design around the human-trigger rule from day one: decide where a human-equivalent action will come from before you wire flows together.
- Put the merge decision in the hands of whoever the change is for. Here that's a synthetic customer approving an exact commit.
The community is converging on the same line
A few posts I read while building this landed in the same place from different directions:
- ๐งช I automated my refactoring method with GitLab Duo agents. The agent writes the fix, a CI gate decides the merge. by kargut: another Transcend entry where the agent writes and a deterministic gate decides.
- ๐ง Put a Deterministic Gate Between Generated Code and Main by kongkong1: protected paths and patch checks before generated code reaches review.
- ๐ Five AI Agents After the Code: What GitLab Duo Did in a Real Pipeline by devaland: real Duo agents in a pipeline, and what a human still decides.
Links
- Repo (MIT): https://gitlab.com/gitlab-ai-hackathon/transcend-october-2026/7630733/ghost-customers
- Devpost: https://devpost.com/software/ghost-customers
- Demo video: https://youtu.be/uqyKiNd5_RY
If you've hit the human-trigger rule on GitLab Duo and solved it differently, I'd love to hear how.
Top comments (0)