Skip to content
Building Heterogeneous AI Agent Pipelines: A Hands-On Guide to Cross-Account, Multi-Platform Automation with Claude Code, ChatGPT, Gemini and Grok

Building Heterogeneous AI Agent Pipelines: A Hands-On Guide to Cross-Account, Multi-Platform Automation with Claude Code, ChatGPT, Gemini and Grok

サイショウカ
画像: xAI Grok生成
111 分で読めます
0
0 件のコメント
3 回表示

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 --help and the official docs for Claude Code 2.1.278. The orchestrator, broker and poll worker in section 4 were actually run: using a mock claude executable, 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 --help output 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) 34[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 78[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 1011[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 -p invocation 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 through tools:) 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:

PlatformCore strengthsRole and typical outputHow to automate it
GrokReal-time web and X search, tracking fast-moving techResearcher: 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
GeminiVery long context, multimodal parsing of PDFs, images and videoAnalyst: legacy-repo summaries, digests of huge logs and long documentsGemini CLI (gemini -p)
ChatGPT / CodexReasoning, complex algorithm design, writing specificationsArchitect: interface specs, test checklists, HANDOVER.mdCodex CLI (codex exec) or the OpenAI API
Claude Code (acc_a)Driving the terminal, multi-file edits, test-driven iterationLead implementer: application code, unit testsclaude -p
Claude Code (acc_b) or another vendorIndependent context, read-only permissionsReviewer: a review report with an explicit pass / reject verdictclaude -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.md at the project root. Codex, Cursor, Jules and others read it natively. Gemini CLI reads GEMINI.md by default, but you can set "context": {"fileName": "AGENTS.md"} in .gemini/settings.json. For Claude Code, one line — @AGENTS.md — in CLAUDE.md imports 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 Commit is 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-ID as 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, not git diff HEAD~1. The implementation stage can easily produce several commits — especially after a reject-and-reimplement round — and HEAD~1 only shows the last one. The three-dot form diffs everything from the merge base to HEAD.

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 FileOpenedEvent and FileClosedNoWriteEvent; saving via "write a temp file, then rename" gives the target a single FileMovedEvent and on_modified never fires at all; and one ordinary write can fire on_modified several times. Hence the handler subscribes only to write events, and a threading.Event coalesces 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-permissions is a contradiction these examples commit all the time.
  • --output-format json returns fields such as session_id, num_turns and total_cost_usd. The orchestrator records them in history, 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 immediately

The broker is short, but four things in it are deliberate:

  • Authentication and bind address. Many examples listen on 0.0.0.0 with 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] = payload lets 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.md in the payload means nothing on another machine. The payload carries git_commit instead, and the artifacts themselves travel through the Git remote.
  • Know your delivery semantics. poll deletes 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.md

Two 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.ts
    • tests/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:

  1. The agent reads the scope and constraints in HANDOVER.md.
  2. It writes src/cache/CacheManager.ts and its unit tests, running npm test itself and iterating until they pass.
  3. Once the agent exits, the orchestrator runs npm test again on its own — and that result is the one that counts.
  4. On a pass, the orchestrator commits the change (with a Pipeline-ID trailer) and advances the state to REVIEW.

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 is APPROVE, the pipeline finishes. If it's CHANGES_REQUESTED — or there's no verdict line at all — the report goes into feedback.md and 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

  1. 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.

  2. Least privilege per role

    RolePermission modeAllowedDenied
    ImplementeracceptEditsFile edits; Bash(npm test *), Bash(npx tsc *)All other Bash commands (nobody approves them in -p mode, so they're denied)
    ReviewerdontAskRead, Grep, GlobEdit, Write, Bash, network tools
  3. Permission rules are guardrails, not a security boundary Bash(npm test *) allows a command prefix, but what npm test actually runs is decided by scripts.test in package.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.enabled setting, which gives Bash filesystem and network isolation on macOS, Linux and WSL2) is a useful extra layer.
    • claude --help describes --dangerously-skip-permissions as "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.json scripts and CI configuration; the review prompt in section 4 already does.

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 reach HANDOVER.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:

  1. Per invocation: --max-turns caps the turns in one run, --max-budget-usd caps the spend of one run (it only works in -p mode), and subprocess.run adds a timeout.
  2. Per pipeline: retry_count totals failed tests, rejected reviews and timeouts; at MAX_RETRIES the status becomes HUMAN_INTERVENTION_REQUIRED. That logic already lives in the section 4 orchestrator's fail(). The retry cap has to be wired into the state transitions themselves: defining a check_loop_limit() that nothing calls on each failure, with a retry_count that never increments, is no cap at all.
  3. No-progress detection: compute the index's tree hash with git write-tree and 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.json

Finally, 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-turns and --max-budget-usd on 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)