Wiring Grok, Gemini, ChatGPT/Codex and Claude Code into one automated pipeline isn't about how many models you plug in — it's about who advances the state, who guards the gates, and where untrusted content stops. We walk through a tested watchdog orchestrator, an authenticated FastAPI broker and a handover-protocol template, fixing the traps common versions fall into: state stuck at IN_PROGRESS, reviewers handed write access, `git diff HEAD~1` missing commits, TOTP secrets parked in Redis. Plus prompt injection across agents, the `ANTHROPIC_API_KEY` credential-precedence trap, and why a second account never buys you a second perspective.
Audience: software engineers, systems architects, AI DevTools developers, technical geeks
Topics: collaboration patterns for heterogeneous AI agents, decoupling state across accounts, a context handover protocol, a local relay service (broker), and building the automated pipeline itself
Every Claude Code flag in this article was checked against
claude --helpand the official docs for Claude Code 2.1.278. The orchestrator, broker and poll worker in section 4 were actually run: using a mockclaudeexecutable, we exercised five scenarios (pass first time, tests fail then pass, review rejects then approves, no progress, three rejections in a row) and every state transition came out as expected. These CLIs move fast — when you read this, trust your own--helpoutput over this article.
1. Background and Design Principles
1.1 The limits of a single agent
As software projects grow more complex, relying on a single LLM or a single AI agent terminal runs into predictable bottlenecks:
- Context pollution and forgetting: after a long conversation, the agent gradually loses the architectural constraints it was given early on, drifting semantically and eventually hallucinating. Constraints that must hold for the life of the project belong in instruction files that are re-read at every start (
CLAUDE.md,AGENTS.md), not in chat history. - Quota and billing boundaries: long stretches of heavy code generation hit a single platform's usage limits quickly. Spreading different kinds of work across different platforms is a form of load splitting in its own right — but that is not the same as "open several accounts on the same service and rotate them", which section 1.3 deals with separately.
- Single-model blind spots: models differ in reasoning, refactoring, bulk log analysis and real-time research. No single model covers all of it well.
- Self-audit risk: an agent that both writes the code and audits it for security will reliably miss its own blind spots.
1.2 A decoupled, cross-account, cross-platform architecture
The core idea behind a cross-account, multi-platform agent pipeline is to turn implicit conversational memory into explicit state artifacts:
1[1] Research ── Grok: real-time web / X search; Gemini: long documents and multimodal material
2 │ Output: docs/research/*.md (every claim carries a source URL)
3 ▼
4[2] Spec ── ChatGPT / Codex: reasoning, interface definitions, test checklist
5 │ Output: interface files, test checklist, .pipeline/HANDOVER.md
6 │ ★ Human checkpoint: architecture decisions are signed off here
7 ▼
8[3] Implement ── Claude Code (profile acc_a): reads and writes the repo, runs tests
9 │ Gate: the orchestrator runs npm test itself and commits only on a pass
10 ▼
11[4] Review ── Claude Code (profile acc_b, read-only) or a model from another vendor
12 APPROVE → done; CHANGES_REQUESTED → back to [3]
13
14State bus: the Git repo (code and commit history) + .pipeline/ (HANDOVER.md, pipeline_state.json, not committed)With a state bus and a handover protocol in place, agents on different accounts and platforms can relay work asynchronously without ever sharing login credentials or API keys. In practice there are two buses: code and commit history travel through Git, and pipeline state travels through the .pipeline/ directory (remember to add it to .gitignore).
1.3 What "cross-account" actually buys you
A common pattern is to run the reviewer under a "second account" in the hope of getting an independent perspective. Two separate things are tangled together there:
- An independent context does not need a second account. Every
claude -pinvocation is a brand-new session with no view of the implementer's conversation. Claude Code's built-in subagents (.claude/agents/*.md, with tools restricted throughtools:) also get their own context window. - A second account does not give you a second perspective. The same model under a different login has exactly the same trained preferences and blind spots. For a genuine cross-audit, the review stage should use a model from another vendor (see the alternative at the end of Step 4 in section 5).
So is there any point to separate config directories (CLAUDE_CONFIG_DIR) and separate accounts? Yes — for isolation, not for capacity: keeping a work organization apart from a personal account, giving the pipeline its own separately billed API key with a spend limit, containing the blast radius if any one role's credentials leak, and keeping audit logs attributable per role.
There is one more boundary worth stating plainly. Anthropic's Claude Code legal and compliance page says the advertised usage limits for Pro and Max plans assume ordinary, individual usage, and that developers building products or services should use API key authentication through the Claude Console. Pooling several subscription accounts and rotating them through an unattended 24/7 pipeline both breaks that assumption and builds your pipeline on something that can stop working at any moment. For unattended automation, use an API key and set a spend limit in the Console. The other vendors have their own terms, and they are worth reading one by one before you go live.
2. The Heterogeneous Agent Matrix and Division of Labor
To get the most out of each model, assign pipeline roles according to what each one does natively:
| Platform | Core strengths | Role and typical output | How to automate it |
|---|---|---|---|
| Grok | Real-time web and X search, tracking fast-moving tech | Researcher: technology-selection reports, notes on recent API changes (with sources) | Grok Build CLI (grok -p, beta); or the xAI API (OpenAI-compatible) with the server-side web_search / x_search tools |
| Gemini | Very long context, multimodal parsing of PDFs, images and video | Analyst: legacy-repo summaries, digests of huge logs and long documents | Gemini CLI (gemini -p) |
| ChatGPT / Codex | Reasoning, complex algorithm design, writing specifications | Architect: interface specs, test checklists, HANDOVER.md | Codex CLI (codex exec) or the OpenAI API |
| Claude Code (acc_a) | Driving the terminal, multi-file edits, test-driven iteration | Lead implementer: application code, unit tests | claude -p |
| Claude Code (acc_b) or another vendor | Independent context, read-only permissions | Reviewer: a review report with an explicit pass / reject verdict | claude -p in a read-only configuration; or codex exec --sandbox read-only |
Two further notes:
- This article does not hard-code model versions. o1/o3, GPT-4o and grok-3, all common in tutorials like this one, are no longer any vendor's current mainstay in 2026 — grok-3 was actually retired in May 2026, with requests redirected to a newer model. Model names belong in pipeline configuration as parameters, not in the architecture diagram.
- Long context is no longer a Gemini-only selling point. Mainstream flagship models now routinely offer context windows of several hundred thousand to a million tokens. The more practical reasons to hand long documents to Gemini are its multimodal parsing, its separate quota, and the different perspective you get simply by having another model read the material.
3. The Context Handover Standard
Seamless handover between agents requires a standard handover-file protocol, one that is both human-readable and machine-parseable.
First, separate two kinds of context:
- Static constraints (coding conventions, directory boundaries, things never to do) go in
AGENTS.mdat the project root. Codex, Cursor, Jules and others read it natively. Gemini CLI readsGEMINI.mdby default, but you can set"context": {"fileName": "AGENTS.md"}in.gemini/settings.json. For Claude Code, one line —@AGENTS.md— inCLAUDE.mdimports it. One file, shared by every agent. - Dynamic handover (how far this task has got, and what comes next) goes in
.pipeline/HANDOVER.md, updated at the end of every stage.
3.1 A structured HANDOVER.md
Every agent writes (or updates) .pipeline/HANDOVER.md when it finishes its stage:
1# Agent Handover Protocol v1.1
2
3## 1. Task Metadata
4
5- **Source Agent**: codex (specification stage)
6- **Target Agent**: claude-code:acc_a (implementation stage)
7- **Timestamp**: 2026-09-21T11:30:00+09:00
8- **Pipeline ID**: pipe_feat_auth_v2_88f9a
9- **Base Commit**: 3f2a9c1e7b4d
10
11## 2. Goal & Scope
12
13- **Goal**: Add TOTP-based two-factor authentication (2FA) to the existing JWT login flow.
14- **In-Scope**: `src/auth/`, `src/services/totpService.ts`, `src/middleware/auth.ts`, `tests/auth/`
15- **Out-of-Scope**: UI components; the signing algorithm and key rotation in `src/config/jwt.ts`.
16
17## 3. Completed Actions
18
19- [x] Defined the `IAuthService` interface (see `docs/specs/auth_spec.md`).
20- [x] Chose `otplib` v13 as the TOTP library.
21
22## 4. Context & Constraints
23
24- **Key files**:
25 - `docs/specs/auth_spec.md`: the interface definition to follow exactly.
26 - `src/config/jwt.ts`: read-only reference; do not modify.
27- **Known pitfalls**:
28 - TOTP secrets are long-lived credentials: persist them encrypted in the database, never
29 only in Redis or process memory. Redis does exactly two jobs: recording the last time step
30 each user successfully verified in (RFC 6238 §5.2 says an OTP must not be accepted a
31 second time within the same time step), and rate-limiting failed attempts.
32 - otplib v13 is a complete rewrite: the `authenticator` export is gone, and `verify()` is now
33 async and returns an object (read `result.valid`). Most v12 examples online will not work
34 as-is; the `afterTimeStep` option handles replay protection.
35 - The `auth.ts` middleware depends on the custom `AppError` class; never throw a bare `Error`.
36
37## 5. Acceptance Criteria
38
39- `npm test` passes, with line coverage of `totpService` at 90% or above.
40- Submitting the same OTP a second time within one time step returns 401.
41
42## 6. Next Actions for Receiver
43
441. Run `npm install otplib@^13`.
452. Implement `src/services/totpService.ts` according to `docs/specs/auth_spec.md`.
463. Write unit tests for `totpService`.
474. Do not run git commit — the orchestrator commits once the tests pass.
48
49## 7. Open Questions
50
51- How recovery codes are generated and stored is still undecided; out of scope for this stage.Several design choices in this template deserve a closer look, because each one guards against a common handover mistake:
- Scope and instructions must agree. If In-Scope does not include
src/services/but the next actions ask for a new file there, the agent either oversteps or gets stuck. - Don't let a handover file carry a wrong design decision. A common line is "for multi-instance deployments, TOTP secrets must be stored in Redis". But a TOTP secret is a long-lived credential, like a password, and belongs encrypted in the database. Redis is a cache — keys can be evicted, and persistence may not be enabled — so what it should hold is replay-guard records and rate-limit counters. A wrong constraint in a handover file is faithfully executed downstream as a settled decision.
- "JWT-based 2FA" is a muddled phrase. JWT handles the session and TOTP provides the second factor; they are orthogonal. The goal should read "add TOTP to the JWT login flow".
- Acceptance criteria and open questions are not optional. Without acceptance criteria, "done" is whatever the agent declares. Without a list of open questions, the agent makes those decisions for you.
- Timestamps use ISO 8601, and the
Base Commitis recorded for the diff in section 3.3.
3.2 A machine-readable state machine: pipeline_state.json
Alongside the Markdown, a JSON file records atomic state changes so the orchestration scripts (watcher or broker) can decide what happens next:
1{
2 "pipeline_id": "pipe_feat_auth_v2_88f9a",
3 "current_stage": "IMPLEMENTATION",
4 "status": "AWAITING_EXECUTION",
5 "base_commit": "3f2a9c1e7b4d",
6 "retry_count": 0,
7 "updated_at": "2026-09-21T11:30:00+09:00",
8 "history": [
9 {
10 "stage": "RESEARCH",
11 "agent": "grok",
12 "status": "COMPLETED",
13 "output_artifacts": ["docs/research/2fa.md"]
14 },
15 {
16 "stage": "SPECIFICATION",
17 "agent": "codex",
18 "status": "COMPLETED",
19 "output_artifacts": ["docs/specs/auth_spec.md", ".pipeline/HANDOVER.md"]
20 }
21 ]
22}status moves along AWAITING_EXECUTION → IN_PROGRESS → (then the next stage's AWAITING_EXECUTION, COMPLETED, or HUMAN_INTERVENTION_REQUIRED). There is deliberately no next_agent_target field: which agent runs which stage is orchestrator configuration, and writing it into the state file as well only creates two sources of truth that can disagree.
More important is who is allowed to write this file. Upstream (a human, or the broker's poll worker in section 4) only ever sets it to AWAITING_EXECUTION. Every transition after that is made by the orchestrator — agents never write the state file. An LLM can emit invalid JSON, skip a stage, or announce "done" while the tests are failing. The state machine should advance on exit codes and test results, not on an agent's self-report.
3.3 Git as the semantic handover channel
For stages that change code, the change itself is the strongest context there is. Taking the specification stage as an example, it ends with a commit:
1git add docs/specs/auth_spec.md src/auth/IAuthService.ts
2git commit -m "feat(auth): define 2FA spec and IAuthService interface
3
4- Add IAuthService interface
5- Store TOTP secrets encrypted in the DB; Redis only for replay guard and rate limits
6
7Pipeline-ID: pipe_feat_auth_v2_88f9a
8Handover-To: claude-code:acc_a"A few details:
- Name the paths to commit explicitly instead of
git add ., which sweeps in.env, build output and.pipeline/along with everything else. - Write
Pipeline-IDas a Git trailer (in the last paragraph of the message).git log --grep="Pipeline-ID: pipe_feat_auth_v2_88f9a"then finds every commit in one pipeline run. - Downstream uses
git diff <base_commit>...HEAD, notgit diff HEAD~1. The implementation stage can easily produce several commits — especially after a reject-and-reimplement round — andHEAD~1only shows the last one. The three-dot form diffs everything from the merge base toHEAD.
The diff tells the next stage what changed; the commit message and HANDOVER.md tell it why. You need both. And a diff does not include the callers that weren't touched, so the reviewer still has to read surrounding files — which is why the review configuration in section 4 keeps Read and Grep.
4. The Inter-Agent Communication Bus
For independent agents to trigger one another you need an automated communication bus. First, one premise to get straight: the web versions of ChatGPT, Gemini and Grok can't be invoked from a script, and they can't write results into your repository. To bring them into an automated pipeline you switch to each vendor's CLI or API — OpenAI's Codex CLI (codex exec), Google's Gemini CLI (gemini -p), and xAI's Grok Build CLI (grok -p, currently in beta) or the xAI API. Keeping a stage that can't be fully automated as a semi-automated "a human drops the output into the repo" step is perfectly reasonable.
Option A: A shared filesystem and a lightweight watchdog listener
This is the best fit for a single machine with several terminals: a Python script watches .pipeline/pipeline_state.json and launches the next agent when it changes.
The orchestration script, agent_orchestrator.py
1#!/usr/bin/env python3
2"""
3agent_orchestrator.py — watches .pipeline/pipeline_state.json and dispatches local
4Claude Code runs stage by stage.
5
6Three design rules:
71. Only the orchestrator moves state. Agents produce artifacts and exit; they never
8 touch the state file.
92. Gate on exit codes and tests the orchestrator runs itself, not on what agents report.
103. Always write the state file by atomic replace, so no reader ever sees half a JSON.
11Assumes a single orchestrator instance at a time.
12"""
13import json
14import os
15import subprocess
16import tempfile
17import threading
18import time
19from datetime import datetime
20from pathlib import Path
21
22from watchdog.events import FileSystemEventHandler
23from watchdog.observers import Observer
24
25PIPELINE_DIR = Path(".pipeline").resolve()
26STATE_FILE = PIPELINE_DIR / "pipeline_state.json"
27# Expand "~" to an absolute path in code: the shell won't expand a quoted or Python-string "~"
28PROFILES = Path.home() / ".claude_profiles"
29MAX_RETRIES = 3
30AGENT_TIMEOUT_SEC = 30 * 60
31
32STAGES = {
33 "IMPLEMENTATION": {
34 "profile": "acc_a",
35 "prompt": (
36 "Read .pipeline/HANDOVER.md and complete the implementation, with unit tests, "
37 "within the scope it defines. If .pipeline/feedback.md exists, address every "
38 "issue it lists first. Do not modify anything under .pipeline/ and do not run git commit."
39 ),
40 "flags": [
41 "--max-turns", "60",
42 "--max-budget-usd", "5",
43 "--permission-mode", "acceptEdits",
44 "--allowedTools", "Bash(npm test *)", "Bash(npx tsc *)", "Bash(git diff *)", "Bash(git status)",
45 ],
46 },
47 "REVIEW": {
48 "profile": "acc_b",
49 "prompt": (
50 "You are an independent security reviewer. Standard input is the full diff of this change. "
51 "You may read files in the repository for context, but must not modify anything. "
52 "Focus on connection leaks, unhandled promise rejections, Lua scripts built by string "
53 "concatenation for EVAL, multi-key operations that span slots, and changes to package.json "
54 "scripts, CI config or anything else that alters what gets executed. "
55 "Write the review as Markdown; "
56 "the last line must be VERDICT: APPROVE or VERDICT: CHANGES_REQUESTED."
57 ),
58 "flags": [
59 "--max-turns", "30",
60 "--max-budget-usd", "2",
61 # dontAsk: anything that would need approval is denied; only the read-only tools below run
62 "--permission-mode", "dontAsk",
63 "--allowedTools", "Read", "Grep", "Glob",
64 "--disallowedTools", "Edit", "Write", "NotebookEdit", "Bash", "WebFetch", "WebSearch",
65 ],
66 },
67}
68
69wake = threading.Event()
70
71
72class StateFileHandler(FileSystemEventHandler):
73 # Subscribe to write events only. On Linux, watchdog emits opened/closed events even
74 # for plain reads, so on_any_event would wake the orchestrator every time it reads state.
75 # Many tools save via "write a temp file, then rename": the target only sees moved, never modified.
76 def on_modified(self, event):
77 self._check(event.src_path)
78
79 def on_created(self, event):
80 self._check(event.src_path)
81
82 def on_moved(self, event):
83 self._check(event.dest_path)
84
85 def _check(self, path):
86 if Path(path).resolve() == STATE_FILE:
87 wake.set()
88
89
90def load_state():
91 for _ in range(10):
92 try:
93 return json.loads(STATE_FILE.read_text(encoding="utf-8"))
94 except FileNotFoundError:
95 return None
96 except json.JSONDecodeError:
97 time.sleep(0.2) # an external writer may not have finished yet
98 raise RuntimeError(f"{STATE_FILE} is still unparseable")
99
100
101def save_state(state):
102 state["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
103 fd, tmp = tempfile.mkstemp(dir=PIPELINE_DIR, suffix=".tmp")
104 with os.fdopen(fd, "w", encoding="utf-8") as f:
105 json.dump(state, f, ensure_ascii=False, indent=2)
106 os.replace(tmp, STATE_FILE) # atomic replace within one filesystem
107
108
109def git(*args):
110 return subprocess.run(
111 ["git", *args], capture_output=True, text=True, check=True
112 ).stdout
113
114
115def run_claude(stage, stdin_text=None):
116 spec = STAGES[stage]
117 # With -p, an ANTHROPIC_API_KEY in the environment always wins, which defeats the
118 # per-profile isolation. To bill the pipeline to the API instead, drop this filter
119 # and pass a dedicated key explicitly
120 env = {k: v for k, v in os.environ.items() if k not in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN")}
121 env["CLAUDE_CONFIG_DIR"] = str(PROFILES / spec["profile"])
122 # Prompt right after -p; variadic flags such as --allowedTools go at the end
123 cmd = ["claude", "-p", spec["prompt"], "--output-format", "json", *spec["flags"]]
124 # Pass an empty stdin when there's no input; otherwise the child inherits the
125 # orchestrator's stdin and may wait on it forever in a non-TTY environment
126 proc = subprocess.run(
127 cmd, env=env, input=stdin_text or "", capture_output=True, text=True,
128 timeout=AGENT_TIMEOUT_SEC,
129 )
130 try:
131 result = json.loads(proc.stdout)
132 except json.JSONDecodeError:
133 result = {"is_error": True, "result": proc.stderr[-2000:]}
134 result["exit_code"] = proc.returncode
135 return result
136
137
138def record(state, stage, status, result):
139 state.setdefault("history", []).append({
140 "stage": stage,
141 "agent": f"claude-code:{STAGES[stage]['profile']}",
142 "status": status,
143 "session_id": result.get("session_id"),
144 "num_turns": result.get("num_turns"),
145 "cost_usd": result.get("total_cost_usd"),
146 "at": datetime.now().astimezone().isoformat(timespec="seconds"),
147 })
148
149
150def advance(state, stage, next_stage, result):
151 record(state, stage, "COMPLETED", result)
152 state["current_stage"] = next_stage
153 state["status"] = "COMPLETED" if next_stage == "DONE" else "AWAITING_EXECUTION"
154 save_state(state)
155
156
157def fail(state, stage, feedback, result, back_to=None, give_up=False):
158 # retry_count counts failures across the whole pipeline: failed tests, rejected reviews, timeouts
159 state["retry_count"] = state.get("retry_count", 0) + 1
160 record(state, stage, "FAILED", result)
161 (PIPELINE_DIR / "feedback.md").write_text(feedback, encoding="utf-8")
162 if give_up or state["retry_count"] >= MAX_RETRIES:
163 state["status"] = "HUMAN_INTERVENTION_REQUIRED"
164 notify(f"Pipeline {state['pipeline_id']} stopped at {stage}: {feedback[:200]}")
165 else:
166 state["current_stage"] = back_to or stage
167 state["status"] = "AWAITING_EXECUTION"
168 save_state(state)
169
170
171def notify(message):
172 print(f"[!] {message}", flush=True) # swap for a webhook alert in production; see section 6.3
173
174
175def implement(state):
176 result = run_claude("IMPLEMENTATION")
177 if result.get("is_error") or result["exit_code"] != 0:
178 return fail(state, "IMPLEMENTATION", str(result.get("result", "")), result)
179 tests = subprocess.run(["npm", "test"], capture_output=True, text=True)
180 if tests.returncode != 0:
181 return fail(state, "IMPLEMENTATION", tests.stdout[-4000:] + tests.stderr[-2000:], result)
182
183 git("add", "-A", "--", ".", ":(exclude).pipeline")
184 tree = git("write-tree").strip()
185 # Same tree as HEAD or the previous round: the agent is going in circles, and more runs just burn tokens
186 if tree in (state.get("last_tree"), git("rev-parse", "HEAD^{tree}").strip()):
187 return fail(state, "IMPLEMENTATION", "This round produced no new changes; treating it as no progress.", result, give_up=True)
188 state["last_tree"] = tree
189 git("commit", "-m", f"feat: implement {state['pipeline_id']}\n\nPipeline-ID: {state['pipeline_id']}")
190 (PIPELINE_DIR / "feedback.md").unlink(missing_ok=True)
191 advance(state, "IMPLEMENTATION", "REVIEW", result)
192
193
194def review(state):
195 diff = git("diff", f"{state['base_commit']}...HEAD", "--", ".", ":(exclude).pipeline")
196 result = run_claude("REVIEW", stdin_text=diff)
197 report = str(result.get("result", ""))
198 (PIPELINE_DIR / "audit_report.md").write_text(report, encoding="utf-8")
199 if result.get("is_error") or result["exit_code"] != 0:
200 return fail(state, "REVIEW", report, result)
201 if report.rstrip().endswith("VERDICT: APPROVE"):
202 return advance(state, "REVIEW", "DONE", result)
203 # A missing verdict line counts as a rejection: better one extra round than an unreviewed change
204 fail(state, "REVIEW", report, result, back_to="IMPLEMENTATION")
205
206
207HANDLERS = {"IMPLEMENTATION": implement, "REVIEW": review}
208
209
210def tick():
211 state = load_state()
212 while (
213 state
214 and state.get("status") == "AWAITING_EXECUTION"
215 and state.get("current_stage") in HANDLERS
216 ):
217 stage = state["current_stage"]
218 state["status"] = "IN_PROGRESS" # if the process dies after this, reset to AWAITING_EXECUTION by hand
219 save_state(state)
220 print(f"[>] {state['pipeline_id']}: {stage}", flush=True)
221 try:
222 HANDLERS[stage](state)
223 except Exception as exc: # timeouts, git failures and the like also count as retries
224 fail(state, stage, f"orchestrator error: {exc!r}", {})
225 state = load_state()
226
227
228if __name__ == "__main__":
229 PIPELINE_DIR.mkdir(exist_ok=True)
230 observer = Observer()
231 observer.schedule(StateFileHandler(), str(PIPELINE_DIR), recursive=False)
232 observer.start()
233 print(f"[+] Orchestrator started, watching {STATE_FILE}", flush=True)
234 wake.set() # check once at startup to pick up anything queued while we were down
235 try:
236 while True:
237 wake.wait(timeout=30) # file events are the main trigger; a 30 s poll is the fallback
238 wake.clear()
239 tick()
240 except KeyboardInterrupt:
241 pass
242 finally:
243 observer.stop()
244 observer.join()The minimal examples you usually find online are a few dozen lines: read the JSON in an on_modified callback, flip the status, then subprocess.run an agent with --dangerously-skip-permissions. The idea is right, but copied as-is it walks into the following traps, each of which the implementation above handles:
- File events are messier than they look. We tested watchdog 6.0.0 on Linux: merely reading the file fires
FileOpenedEventandFileClosedNoWriteEvent; saving via "write a temp file, then rename" gives the target a singleFileMovedEventandon_modifiednever fires at all; and one ordinary write can fireon_modifiedseveral times. Hence the handler subscribes only to write events, and athreading.Eventcoalesces a burst of events into one pass. - Dispatch must not block the event thread. Running an agent that may take half an hour inside a watchdog callback stalls event delivery entirely. Here the callback only "wakes" the main loop, which does the actual work.
- State has to move forward, not just in. The naive version sets
IN_PROGRESS, then neither checks the exit code nor advances the state, relying entirely on the agent to edit the JSON. The moment an agent skips that step, the pipeline hangs forever without a single error. - The reviewer needs no write access at all. The diff goes in on stdin, the report comes back on stdout, and the orchestrator writes
audit_report.md. Demanding a read-only reviewer while launching it with--dangerously-skip-permissionsis a contradiction these examples commit all the time. --output-format jsonreturns fields such assession_id,num_turnsandtotal_cost_usd. The orchestrator records them inhistory, so you can trace afterwards what each round cost and how many turns it took.
Each config directory needs one interactive login first: run CLAUDE_CONFIG_DIR="$HOME/.claude_profiles/acc_a" claude, then /login. According to the official docs, once CLAUDE_CONFIG_DIR is set, the credentials file (and on macOS, the Keychain entry) is keyed to that directory. For fully unattended runs, claude setup-token generates a one-year OAuth token that you pass in as CLAUDE_CODE_OAUTH_TOKEN; or use ANTHROPIC_API_KEY and pay API rates. Watch the precedence: in -p mode, an ANTHROPIC_API_KEY in the environment is always used when present, ahead of the OAuth token and the subscription login — which is why the code above filters it out of the child's environment.
Option B: A lightweight Python HTTP broker (a relay across machines)
If your agents live on different physical machines or in CI, you can run a FastAPI broker:
1# broker_server.py — a minimal relay for handovers across machines (single process, for demonstration)
2import os
3import secrets
4from collections import defaultdict, deque
5
6from fastapi import Depends, FastAPI, Header, HTTPException
7from pydantic import BaseModel
8
9app = FastAPI(title="Multi-Agent Handover Broker")
10TOKEN = os.environ["BROKER_TOKEN"] # refuse to start without a token
11
12
13def require_token(authorization: str = Header(default="")):
14 if not secrets.compare_digest(authorization.encode(), f"Bearer {TOKEN}".encode()):
15 raise HTTPException(status_code=401)
16
17
18class HandoverPayload(BaseModel):
19 pipeline_id: str
20 sender_agent: str
21 receiver_agent: str
22 stage: str
23 handover_markdown: str
24 git_commit: str # a commit already pushed to the shared remote: send references, not local paths
25
26
27# One FIFO queue per receiver. In process memory only: lost on restart, and no multiple workers
28queues: dict[str, deque] = defaultdict(deque)
29
30
31@app.post("/api/v1/handover", dependencies=[Depends(require_token)])
32async def enqueue(payload: HandoverPayload):
33 queues[payload.receiver_agent].append(payload)
34 return {"status": "ACK", "queued": len(queues[payload.receiver_agent])}
35
36
37@app.get("/api/v1/poll/{agent_id}", dependencies=[Depends(require_token)])
38async def poll(agent_id: str):
39 queue = queues.get(agent_id)
40 if not queue:
41 return {"has_task": False, "data": None}
42 return {"has_task": True, "data": queue.popleft()}
43
44
45if __name__ == "__main__":
46 import uvicorn
47
48 # Listen on localhost only. For access across machines, put it behind a VPN / Tailscale or a TLS reverse proxy
49 uvicorn.run(app, host="127.0.0.1", port=8080)On the machine that runs Claude Code, a poll worker turns each task it claims into an Option A state file and leaves the rest to the orchestrator:
1# poll_worker.py — runs on the Claude Code machine and hands broker tasks to the Option A orchestrator
2import json
3import os
4import subprocess
5import time
6from pathlib import Path
7
8import httpx
9
10BROKER = os.environ.get("BROKER_URL", "http://127.0.0.1:8080")
11HEADERS = {"Authorization": f"Bearer {os.environ['BROKER_TOKEN']}"}
12AGENT_ID = "claude-code-acc-a"
13STATE_FILE = Path(".pipeline/pipeline_state.json") # .pipeline/ belongs in .gitignore
14
15
16def busy():
17 if not STATE_FILE.exists():
18 return False
19 return json.loads(STATE_FILE.read_text("utf-8"))["status"] in ("AWAITING_EXECUTION", "IN_PROGRESS")
20
21
22while True:
23 time.sleep(10)
24 if busy(): # the orchestrator still has work in hand; don't claim a new task yet
25 continue
26 try:
27 resp = httpx.get(f"{BROKER}/api/v1/poll/{AGENT_ID}", headers=HEADERS, timeout=10)
28 resp.raise_for_status()
29 except httpx.HTTPError as exc:
30 print(f"[worker] broker unreachable: {exc}", flush=True)
31 continue
32 task = resp.json()
33 if not task["has_task"]:
34 continue
35
36 data = task["data"]
37 subprocess.run(["git", "fetch", "origin"], check=True)
38 subprocess.run(["git", "checkout", "-B", f"pipeline/{data['pipeline_id']}", data["git_commit"]], check=True)
39 STATE_FILE.parent.mkdir(exist_ok=True)
40 (STATE_FILE.parent / "HANDOVER.md").write_text(data["handover_markdown"], encoding="utf-8")
41 state = {
42 "pipeline_id": data["pipeline_id"],
43 "current_stage": data["stage"],
44 "status": "AWAITING_EXECUTION",
45 "base_commit": data["git_commit"],
46 "retry_count": 0,
47 "history": [],
48 }
49 tmp = STATE_FILE.with_suffix(".tmp")
50 tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
51 tmp.replace(STATE_FILE) # atomic replace; the Option A orchestrator wakes up immediatelyThe broker is short, but four things in it are deliberate:
- Authentication and bind address. Many examples listen on
0.0.0.0with no authentication at all, while the tasks the broker delivers end up with an agent that can run commands — effectively exposing a machine's shell to the whole subnet. - A queue has to actually be a queue. A "queue" implemented as
message_queue[receiver] = payloadlets the second task silently overwrite the first. Here each receiver gets a FIFO queue. - Don't send local paths across machines. A local path such as
docs/spec.mdin the payload means nothing on another machine. The payload carriesgit_commitinstead, and the artifacts themselves travel through the Git remote. - Know your delivery semantics.
polldeletes as it hands out, which is at-most-once: if the worker crashes after claiming a task, that task is gone. In production, switch to a queue with acknowledgements, such as a Redis Streams consumer group (XREADGROUP+XACK) or SQS with its visibility timeout.
How the agents use it: the "ChatGPT / Gemini node" is really a script you write. It calls Codex CLI, Gemini CLI or a vendor API to do the work, pushes the result to the Git remote, and then sends a POST to /api/v1/handover. The ChatGPT web app has no way to reach a broker inside your network.
Option C: Redirecting CLI standard input and output (stdio / pipes)
At the shell level, a Unix pipe can hand one platform's output straight to another agent:
1#!/usr/bin/env bash
2# pipeline_pipe.sh
3set -euo pipefail
4mkdir -p .pipeline
5
6echo "=== Step 1: analyze a long document with Gemini CLI ==="
7gemini -p "@docs/legacy_architecture.pdf Summarize the core performance bottlenecks in detail, as concise Markdown" \
8 > .pipeline/gemini_summary.md
9
10echo "=== Step 2: pass the summary to Claude Code on stdin and refactor ==="
11claude -p "Standard input is a summary of architectural bottlenecks. Refactor src/legacy_module.js accordingly, then run npm test." \
12 --permission-mode acceptEdits \
13 --allowedTools "Bash(npm test *)" \
14 --max-turns 40 \
15 < .pipeline/gemini_summary.mdTwo details that are easy to get wrong: the official Google CLI's executable is gemini, not gemini-cli (that's the repository name), and files are pulled in with @path inside the prompt. set -euo pipefail stops the whole pipeline the moment step 1 fails, instead of passing an empty summary downstream.
Step 2 deliberately does not use --dangerously-skip-permissions; it auto-accepts edits and allows only the test command. The reason goes beyond the size of the permission set: a pipe turns the upstream model's output, verbatim, into the downstream agent's instructions. Gemini is reading a PDF, and any passage in that PDF can travel down this pipe and become a command on your machine. That is exactly the problem section 6.2 takes up.
5. Walkthrough: An End-to-End Cross-Platform Agent Pipeline
Here is a semi-automated workflow for a real scenario: a human triggers and signs off the first two stages, and the section 4 orchestrator runs the last two.
Scenario: refactoring an enterprise Redis cache layer, with an independent security review
Step 1: Research (Grok)
- Prompt:
"Research client choices and reconnection strategies for Node.js against Redis Cluster. Give a source link for every conclusion, and note each client library's current maintenance status." - Output:
docs/research/redis_client.md
The prompt deliberately isn't "find the best ioredis reconnection strategy" — that presupposes the answer. Ask about the choice first, and the research comes back with a key fact: the ioredis README says its maintenance is done on a best-effort basis and that node-redis is the recommended client for new projects. Skip research and let the coding agent work from memory, and it will most likely pick whichever library shows up most in its training data.
Requiring a source for every conclusion isn't box-ticking either: search-backed models invent API changes too, and a conclusion without a link shouldn't make it into the next stage.
Step 2: Specification and test design (ChatGPT / Codex)
- Input:
docs/research/redis_client.md - Prompt:
"As lead architect, define the TypeScript interface for CacheManager based on the research report. The interface must not expose any client library's types. Output a unit-test checklist and .pipeline/HANDOVER.md." - Output:
src/cache/ICacheManager.tstests/specs/cache_spec.md.pipeline/HANDOVER.md
"No client library types in the interface" turns node-redis versus ioredis into an implementation detail you can defer and reverse. This is also the single best place in the pipeline for a human sign-off: get the architecture decision wrong and every later stage will execute the mistake very diligently. After sign-off, commit, write that commit's SHA into the state file as base_commit, and set the status to AWAITING_EXECUTION — the orchestrator takes it from there.
Step 3: Core implementation (Claude Code, profile acc_a)
The orchestrator runs the equivalent of:
1CLAUDE_CONFIG_DIR="$HOME/.claude_profiles/acc_a" claude -p \
2 "Read .pipeline/HANDOVER.md and src/cache/ICacheManager.ts, implement CacheManager and add unit tests. Do not run git commit." \
3 --output-format json \
4 --max-turns 60 --max-budget-usd 5 \
5 --permission-mode acceptEdits \
6 --allowedTools "Bash(npm test *)" "Bash(npx tsc *)"Note that this uses $HOME, not CLAUDE_CONFIG_DIR="~/.claude_acc_a": a ~ inside double quotes is not expanded by the shell, so the program receives the literal string ~/.claude_acc_a. Whether that works depends on every program that reads the variable handling ~ itself — rather than bet on it, just write $HOME. And the permission flags are not optional: in -p mode nobody is there to click "allow", so without pre-approval every file edit and test run is denied and the agent can't actually change anything.
What happens:
- The agent reads the scope and constraints in
HANDOVER.md. - It writes
src/cache/CacheManager.tsand its unit tests, runningnpm testitself and iterating until they pass. - Once the agent exits, the orchestrator runs
npm testagain on its own — and that result is the one that counts. - On a pass, the orchestrator commits the change (with a
Pipeline-IDtrailer) and advances the state toREVIEW.
Why not let the agent run git add . && git commit and edit pipeline_state.json itself? Because one missing sentence in the prompt is all it takes for the pipeline to stop right there, with no error; and git add . sweeps in files that should never be committed.
Step 4: Cross security review (Claude Code, profile acc_b)
1git diff "$BASE_COMMIT"...HEAD | CLAUDE_CONFIG_DIR="$HOME/.claude_profiles/acc_b" claude -p \
2 "You are an independent security reviewer. Standard input is the full diff of this change. Focus on connection leaks, unhandled error events and promise rejections, Lua scripts built by string concatenation for EVAL, and multi-key operations that span slots. End with a line reading VERDICT: APPROVE or VERDICT: CHANGES_REQUESTED." \
3 --permission-mode dontAsk \
4 --allowedTools "Read" "Grep" "Glob" \
5 --disallowedTools "Edit" "Write" "Bash" \
6 --max-turns 30 > .pipeline/audit_report.md- Output:
.pipeline/audit_report.md. If the last line isAPPROVE, the pipeline finishes. If it'sCHANGES_REQUESTED— or there's no verdict line at all — the report goes intofeedback.mdand the task returns to Step 3.
"Redis injection" is deliberately absent from the review focus. RESP transmits each command as an array of arguments, so there is no concatenation vulnerability in the SQL-injection sense. The real risks in Redis are Lua scripts assembled by string concatenation (EVAL) and keys without a namespace prefix. Cluster mode adds one more check on multi-key operations: if the keys don't hash to the same slot the command fails outright with CROSSSLOT, and you need a hash tag such as {user:42} to put them in one slot.
For genuine model diversity, swap this step for a model from another vendor — for example, Codex CLI reviewing in a read-only sandbox: codex exec --sandbox read-only "Review the changes in git diff $BASE_COMMIT...HEAD ...". The same model under another account only gets you a clean context, not a second perspective.
6. Engineering Safety, Sandboxing and Fault Tolerance
The more complex a multi-agent pipeline becomes, the more its fault tolerance and security controls matter.
6.1 Permissions and account isolation
-
Config directory isolation (profile sandboxing) Use environment variables to keep each role's authentication state separate, and never share credentials between roles:
alias claude-dev='CLAUDE_CONFIG_DIR="$HOME/.claude_profiles/acc_a" claude' alias claude-audit='CLAUDE_CONFIG_DIR="$HOME/.claude_profiles/acc_b" claude'Defining the aliases in single quotes defers expanding the variable until each use.
-
Least privilege per role
Role Permission mode Allowed Denied Implementer acceptEditsFile edits; Bash(npm test *),Bash(npx tsc *)All other Bash commands (nobody approves them in -pmode, so they're denied)Reviewer dontAskRead,Grep,GlobEdit,Write,Bash, network tools -
Permission rules are guardrails, not a security boundary
Bash(npm test *)allows a command prefix, but whatnpm testactually runs is decided byscripts.testinpackage.json— a file the implementer is allowed to edit. Worse, the orchestrator's own test gate executes it too. So:- Run both the implementer and the test gate inside a container, Dev Container or VM that holds no production credentials and allows only the network egress it needs. Claude Code's built-in sandbox (the
sandbox.enabledsetting, which gives Bash filesystem and network isolation on macOS, Linux and WSL2) is a useful extra layer. claude --helpdescribes--dangerously-skip-permissionsas "Recommended only for sandboxes with no internet access". Turning it on for every agent on the host machine is the most common — and most dangerous — move in multi-agent tutorials.- The reviewer's checklist must include changes to
package.jsonscripts and CI configuration; the review prompt in section 4 already does.
- Run both the implementer and the test gate inside a container, Dev Container or VM that holds no production credentials and allows only the network egress it needs. Claude Code's built-in sandbox (the
6.2 Prompt injection across agents: every handover is a trust boundary
This is the link that articles like this one most often ignore, and it is the most dangerous. The upstream stages of the pipeline are precisely the agents whose job is to read untrusted content: Grok reads web pages and posts on X, Gemini reads PDFs and logs of unknown origin. Once their output flows verbatim into the "next actions" of HANDOVER.md — or, as in Option C, is piped straight in as a prompt — a sentence an attacker planted on a web page can become a command on the implementer's machine.
Simon Willison calls this combination the "lethal trifecta": access to private data, exposure to untrusted content, and the ability to communicate externally. With all three present, data exfiltration is one carefully crafted paragraph away. A multi-agent pipeline assembles all three almost without trying: the research agent does the reading, the implementer holds the repository and credentials, and the only thing between them is a Markdown file.
Practical mitigations:
- Research output is reference material, not instructions. It lives in
docs/research/, and only after a human or the architecture stage distills it does anything reachHANDOVER.md. The implementer's prompt states explicitly that research material is data, not commands. - Remove at least one leg of the trifecta. Keep production credentials out of the implementer's environment and put its network egress on an allowlist — then even a successful injection finds nothing to steal and nowhere to send it.
- Have the reviewer watch for abnormal behavior specifically: new network calls, new dependencies, modified build scripts.
6.3 Loop protection and token budgets
When the implementer and the reviewer fall into a "change → reject → change → reject" loop, tokens keep burning. Control it in three layers:
- Per invocation:
--max-turnscaps the turns in one run,--max-budget-usdcaps the spend of one run (it only works in-pmode), andsubprocess.runadds atimeout. - Per pipeline:
retry_counttotals failed tests, rejected reviews and timeouts; atMAX_RETRIESthe status becomesHUMAN_INTERVENTION_REQUIRED. That logic already lives in the section 4 orchestrator'sfail(). The retry cap has to be wired into the state transitions themselves: defining acheck_loop_limit()that nothing calls on each failure, with aretry_countthat never increments, is no cap at all. - No-progress detection: compute the index's tree hash with
git write-treeand stop immediately if it matches the previous round. This catches "the agent keeps submitting the same thing" much earlier than counting alone.
Every invocation's total_cost_usd is stored in history, so a pipeline's total cost is one command away:
jq '[.history[].cost_usd // 0] | add' .pipeline/pipeline_state.jsonFinally, replace the orchestrator's notify() with a real alert. Below is the Slack Incoming Webhook version; Feishu and DingTalk custom bots differ only in the JSON shape (Feishu: {"msg_type": "text", "content": {"text": ...}}; DingTalk: {"msgtype": "text", "text": {"content": ...}}):
1import json
2import os
3import urllib.request
4
5
6def notify(message):
7 print(f"[!] {message}", flush=True)
8 url = os.environ.get("ALERT_WEBHOOK_URL")
9 if not url:
10 return
11 req = urllib.request.Request(
12 url,
13 data=json.dumps({"text": message}).encode(),
14 headers={"Content-Type": "application/json"},
15 )
16 try:
17 urllib.request.urlopen(req, timeout=10)
18 except OSError as exc: # a failed alert must not take the orchestrator down with it
19 print(f"[!] failed to send alert: {exc}", flush=True)7. Conclusion: Where the Agent Mesh Is Heading
Cross-account, cross-platform AI agent workflows mark the move in AI-assisted development from the era of the lone AI assistant to the era of multi-agent pipelines.
Combine Grok (real-time intelligence), Gemini (long-document and multimodal understanding), ChatGPT / Codex (reasoning and specification) and Claude Code (terminal execution and refactoring), add a standardized handover protocol (HANDOVER.md) and a lightweight dispatch bus, and you can build a high-throughput, well-isolated automated software line whose stages review one another. But the point this article keeps coming back to is that the line's reliability does not depend on how many models you plug in. It depends on who advances the state, who guards the gates, and where untrusted content stops.
As for the future, the interoperability standards are already concrete enough that there's no need to invent a private protocol over Unix domain sockets:
- MCP (Model Context Protocol) connects tools and context to agents. Claude Code can itself act as an MCP server via
claude mcp serve, to be called as a tool by other agents. - The A2A (Agent2Agent) protocol handles communication between agents. In August 2026 it joined the Agentic AI Foundation (AAIF) under the Linux Foundation, with the specification at version 1.0. Agents publish their capabilities as an Agent Card at
/.well-known/agent-card.json. - AGENTS.md, also stewarded by the AAIF, is becoming the de facto standard for sharing project constraints across tools.
Looking back, the hand-rolled pipeline_state.json state machine and broker in this article are essentially a manual version of A2A's task lifecycle and artifact passing — and HUMAN_INTERVENTION_REQUIRED corresponds directly to A2A's input-required state. Build the simplified version by hand first, then migrate to the standard protocol, and you'll know exactly why every field exists.
A checklist for going live:
- Add
.pipeline/to.gitignore, and log in interactively (/login) once in each config directory. - Only the orchestrator moves state; gates look at exit codes and test results.
- The reviewer is read-only: the diff goes in on stdin, the report comes out on stdout.
- The implementer runs in a sandbox, with no production credentials and allowlisted network egress.
- Set
--max-turnsand--max-budget-usdon every invocation, and a retry cap on every pipeline. - Schedule a human sign-off after the specification stage.
- Unattended pipelines use an API key, not a rotation of subscription accounts.

コメント
コメント (0)