跪拜 Guibai
← Back to the summary

OpenTeam Puts Five AI Web Apps in One Group Chat to Surface What a Single Model Hides

Last week during a technical selection, I asked the same question to five models and got five answers. In the end I adopted the one I had wanted from the start.

This is more troublesome than "the model answered incorrectly": the real problem with a single model is not that it can be wrong, but that you have no way to discover it is wrong. When you only have one perspective, that perspective is all of reality. You can only validate it with your own judgment, and your judgment happens to be the weakest link in the whole thing.

So I built something: pull the web versions of ChatGPT, Claude, Gemini, DeepSeek, and Grok into the same group chat. @ who you want to speak, multiple AIs can see what each other said and can rebut each other.

It's called OpenTeam, a Chrome extension. This article doesn't talk about "I made a thing"; it talks about three engineering problems: why not use an API, how the group chat works, and the hardest one — how do you know when a web AI has finished answering.


1. Why not use an API

First, what I gave up.

Using an API for multi-model orchestration is the standard approach: mature, stable, controllable. I didn't use it for one reason: the cost structure is wrong.

The token consumption of a multi-model discussion is not linear. Five roles discuss for three rounds; each round each role reads everyone else's messages, so the number of messages grows quadratically. A 20,000-character technical proposal read by five models is 100,000 characters; three rounds of discussion starts at 300,000 characters. At API prices, that's a few yuan to tens of yuan per review task, depending on context length.

But with the web versions: I already have accounts on these five platforms, the web quotas are free, subscription-based, money I'm already paying. Buying an API again means paying a second time for the same compute.

The second reason is threshold. Five platforms = five keys = five bills = five rate-limit rules = five SDKs. Let any one expire and the whole chain breaks. The web versions don't have that problem: when you're logged in, it works.

The costs must be stated plainly, because they are the entire fragility of this approach:

An API gives you a clean pipe; a web session gives you an interface being operated by a person. You have to guess its state. And guessing state is 80% of this project's engineering effort.


2. Architecture of a group chat

team.html            Extension's group chat page (message stream, member list, @ input)
  ↓ chrome.runtime
background.js        service worker: routing, queue, message bus, context trimming
  ↓ dispatch per site
iframe × N           One iframe per AI site (session isolation)
  ↓
content.js           Injected into each site's frame (all_frames: true)
                     Handles filling input, clicking send, observing streaming output, grabbing reply
  ↓
reply text / image   Sent back to service worker, then aggregated into the group chat stream

Three design decisions are worth expanding.

1. Why iframes instead of new tabs.

New tabs are the simplest approach, but they have three problems: sessions are not isolated (multiple roles on the same site step on each other), switching tabs triggers browser throttling of background pages, and you can't "point-observe" a role — you'd have to repeatedly query via chrome.tabs.

Iframes are parallel, isolated, and point-observable. The cost is that sites forbid iframe embedding by default, so you have to modify response headers — that's the only reason declarativeNetRequest exists. It's also the place most likely to be caught in review: an extension requesting to override X-Frame-Options and Content-Security-Policy will make a reviewer ask why.

2. Why the content script must live in each site's frame.

Cross-origin. From the extension page you can't get chatgpt.com's DOM, not a single line. All writing to input fields and observing output regions must happen inside that site's frame context. So the content script is registered with all_frames: true and injected along with each target site's frame.

3. Why each site gets an adapter rather than one set of general logic.

The DOM structures of the five sites have nothing in common. One input is a contenteditable, one is a textarea, one is wrapped in two layers of shadow DOM. The send button, one is <button>, another relies on Enter. For streaming output, one appends a <p> every 100ms, another repaints the whole block.

So in the code there is a ChatSiteAdapter interface, with one implementation per site, each answering four questions: where is the input, how to send, how to recognize the reply container, and — whether this reply is finished or not.


3. The hardest problem: how to judge "finished"

This is the code I've changed the most in the whole project, and the easiest to get wrong.

If you have an API, the answer is one line: finish_reason === 'stop'. You don't have that. You only have a DOM that keeps changing. The only thing you can observe is: when does the text length stop growing.

Sounds simple, just poll text length. Actually there are four pitfalls, and each can crash the entire group chat.

Pitfall 1: A pause ≠ completion

A model can pause mid-output. It's thinking, waiting on network, rendering a Markdown code block, or being throttled by the server for two seconds. If you treat "length unchanged for two seconds" as done, you'll get half an answer, and keep getting half answers — because in the next round, other models read the half.

My approach is to tier the "stillness duration":

const RESPONSE_DEBOUNCE_MS = 2500          // observation debounce
const RESPONSE_FINAL_SETTLE_MS = 1500      // final stable threshold
const REPLY_POLL_INTERVAL_MS = 2000        // compensation polling interval
const REPLY_TIMEOUT_MS = 120000            // per-role timeout cap
const SHORT_REPLY_MAX_CHARS = 50
const SHORT_REPLY_STABLE_SETTLE_MS = 5000  // short replies need to stay still longer

A side note: the 120-second per-role timeout wasn't arbitrary. It came after issue #58 (DeepSeek page occasionally triggers "waiting for reply timeout") and was set based on the longest measured response time with some margin.

Back to the tiers, the last line was learned the hard way: short replies need a longer stable time. "OK", "yes", "not needed" — these responses are only a few characters, and the length doesn't change between two polls, so the "stillness" signal has zero discrimination for them. You have to raise the threshold from 1.5 seconds to 5 seconds on short text, otherwise every short answer will be judged complete too early, and you'll see the group chat's five roles all start saying "OK" almost simultaneously.

Pitfall 2: The reply container gets repainted

Some sites don't append nodes; they replace the whole block. The element reference you grabbed a second ago is now detached. Its textContent stays in memory but will never update.

So you can't only recognize the element; you have to recognize "the identity of this round's reply": its position in the DOM tree, its depth, its ancestor chain. In code, that's responseContainers.ts — count all candidate containers, keep only the deepest ones (keepDeepestResponseContainers), because in the site's nested structure, the same reply often appears in three layers of containers.

This pitfall left two records in issues: #57 was "round count wrong, grabbed previous round's history reply", and #60 was "after container was replaced, timeout compensation grabbed nothing". Both were container-identity recognition failures, not the model failing to answer.

Pitfall 3: DOM text ≠ reply text

What do you get from directly reading textContent? You get the code block's copy button text "Copy code", you get "Regenerate", you get the table structure lost after Markdown rendering, and you lose formulas.

So text extraction has a priority: first try the site's own "copy" button (you get clean Markdown), fall back to site-specific Markdown extraction, and finally fall back to textContent. And check the "copy" result's credibility:

const LONG_REPLY_TEXT_LENGTH = 160
const MIN_COPY_TO_DOM_TEXT_RATIO = 0.2

If the DOM has 3,000 characters and the clipboard has only 40, the copy action grabbed the wrong thing — probably the "Copy code" on the toolbar or "Regenerate". In that case, use the DOM text instead of the clipboard. This isSuspiciousCopiedReply check exists to block that kind of mis-grab.

Pitfall 4: What to do after timeout

Two minutes without finishing. What to do? Reporting an error directly is the worst option — the user sees "Claude did not reply" when it may have just finished typing its last word.

So there are two compensation paths: timeout compensation and polling compensation. After timeout, don't give up immediately; grab the current container content once more. If the length is already long enough (meaning it actually finished but didn't trigger the stability judgment), accept it as a normal reply and just log a reply-timeout-compensated entry. Only when the grabbed content is truly empty does it count as failure.

By the way, the DeepSeek editor did not accept the prompt text fixed in v1.0.3 (issue #63) belongs to another class: the "send" step itself failed, but it was misjudged as "sent and waiting for reply". So now after writing into the input, you first verify the content actually got in, then click send.

These three tiers (observe → stability judgment → timeout compensation) together form the only usable definition of "complete" in a world without finish_reason.


4. Cross-talk: the most concealed bug in multi-agent systems

The group chat form has a problem that API form doesn't have: context pollution.

Five roles share one message stream. If each role blindly reads the full history, three bad outcomes appear:

  1. Role A states a wrong conclusion, B reads it and continues reasoning within A's framework, C reads B — the error is amplified over three rounds, and at the end the whole group reaches a consensus that is consistent but wrong. This is an "echo chamber", far more dangerous than a single model answering wrong.
  2. Context explosion. All five roles' histories stuffed in, two rounds hits the limit.
  3. Role persona dilution. A role originally set as a "devil's advocate reviewer", after reading four approvals, starts approving too.

My approach has two layers.

Layer one: independent context budgets. Each site gets a character budget based on its actual context window, not a unified value:

Site Character budget
Gemini 1,000,000
DeepSeek 1,000,000
Claude 500,000
ChatGPT 272,000
Grok 256,000

Trimming is per role, not per group. In the same group, the Claude role can carry 500k characters of history while Grok only carries 256k — they naturally see different-size worlds.

Layer two: isolate first, then collide. Roles have two modes:

The correct order for multi-model review is: first let the five roles each answer in independent mode (so perspectives don't contaminate each other), then switch to collaborative mode so they challenge each other (that's when disagreement becomes valuable). Doing it in reverse — going collaborative from the start — gives you not five opinions but five echoes of one opinion.


5. Where this fits and doesn't fit

Good for:

Not good for, and worse if used:

Three more limits that must be stated openly: only supports Chromium-based browsers; depends on your logged-in web sessions, so it goes down together when an account gets risk-controlled; license is PolyForm Noncommercial 1.0.0, personal non-commercial use only.


6. Installation takes three steps

No clone, no npm, no compilation. The repo's Release has a packaged zip:

  1. From Releases download the latest openteam-extension-v1.0.3.zip, unzip to a directory you won't delete.
  2. Open chrome://extensions, enable "Developer mode" in the top right.
  3. Click "Load unpacked" and select the extracted directory.

Then log in normally to ChatGPT / Claude / Gemini etc. in the browser, click the extension icon to open the group chat page, add roles to the group, and @ them to speak. There are 38 built-in consultant templates to start with; you can also add your own people.

Source code is at github.com/afumu/openteam, TypeScript + Manifest V3, about 48,000 lines.


Finally

This thing hasn't solved "AI can make mistakes"; it solves another problem: when you only have one answer, you lack the ability to judge where it goes wrong.

When five models argue, the disagreement itself is information. You'll see one model notice a boundary condition that none else mentioned, and you'll also see four models make the same mistake together — the latter is often more valuable than the former, because it indicates the mistake is baked into the training data, not into a particular sampling.

So I want to ask you a question, which is also the parameter I've been tuning: If your five AIs disagree, do you want them to vote, to rebut each other, or to each output separately?