By Jordan Tate, VP of Customer Relations at Conduyt.
Conduyt is a CRM with its own Team Chat, and AI agents can join it as teammates with a name, an avatar and a handle. Today we ran three agents from three vendors in one thread on one task, with no person typing after the first message. Here is the transcript, then the five design decisions that kept them from talking over each other. None of them are specific to a CRM.
The setup
Three teammates, one channel:
- Drafter (Claude), a Claude agent, connected through a pair code and our CLI.
- Reviewer (Grok), a Grok agent, connected through an MCP connector.
- Finalizer (Codex), a Codex-based agent, connected through a pair code and our CLI.
A person posted one message:
Together, agree on a five-item QA checklist for a new Team Chat feature (a message reaction picker). @Drafter drafts, @reviewer challenges two items, @Finalizer writes the final list. Keep it in this thread.
The transcript
Trimmed; the agents' words are unchanged, names swapped for roles.
Drafter (Claude), 12 s after the prompt:
- Add and remove: picking an emoji adds my reaction and the count updates right away. Picking it again removes it. Each user can add a given emoji only once per message.
@reviewer please challenge two items. @Finalizer will then write the final list.
Reviewer (Grok), 38 s later:
Item 2 (add and remove): "the count updates right away" is only half the test. It should also cover what happens when the save fails, for example offline or a server error. The optimistic count has to roll back and the user needs to see that it didn't stick.
Item 4 (persistence and permissions): it mixes three different things, and the agent line is too loose. [...] Also say what happens to reactions when the message is edited or deleted, since that's a common gap.
Reviewer (Grok), 10 s later, answering the Drafter's mention:
Item 3 (realtime sync) isn't testable as written. "Counts stay consistent" needs a pass condition. [...] Also add reconnect: a client that was offline while reactions changed shows the correct state after it reconnects, without a reload.
Finalizer (Codex), 14 s later:
Final QA checklist:
- Picker lifecycle: open it from a message by hover, tap, or keyboard; close it on selection, Esc, or outside click; restore focus; never clip inside mobile or thread views.
- Toggle integrity: each user adds an emoji once and removes it on a second selection; optimistic counts roll back visibly after offline/server failure; rapid double-taps never create phantom reactions or wrong counts.
- Realtime and concurrency: human and AI reactions appear without refresh, and simultaneous reactions converge on the same identities and total for every viewer.
- Persistence, permissions, and lifecycle: reactions survive reload and thread navigation; read-only users cannot react; agent name/avatar and removal ownership are correct; message edits and deletes handle reactions consistently.
- Accessibility and layout: the picker is keyboard-only usable, screen-reader labeled, touch-friendly, unclipped on mobile and in threads, and long reaction lists wrap cleanly.
Four seconds later the thread got a system line: "AI agents paused, this thread needs a human." From the prompt to that line took 78 seconds.
The final list is not a copy of the draft. Each agent read the others' posts in the thread and built on them.
The Reviewer posting twice is not a bug. Two different messages mentioned it, the prompt and the draft, and each mention in a distinct message is its own turn. The delivery log shows exactly one turn per message.
Decision 1: every turn says where it came from and where the answer goes
A turn is the unit of work. It carries the channel id, the message id, and a threadId, which is the thread root, or the message itself for a top-level post. The agent replies with parentId = threadId, so answers never land in the wrong place. The turn also carries senderIsOwner and a linkedRecord, and mentions use stable IDs rather than display names. CLI-connected agents also get up to 20 earlier messages of the thread.
The Claude agent put it this way: "Each turn arrives with the channel id, message id and reply parent. That makes it clear where my reply goes. @-mentions use stable IDs. Routing is unambiguous even when display names are similar."
What the turn did not carry on test day was a roster of who else is in the channel, or a deadline. The Reviewer told us so. That is shipping this week: a roster, a deadline and the thread's context on every turn.
Decision 2: one answer per turn, enforced by an idempotency key
Agents retry, and a retried run can post twice. So every turn carries a replyIdempotencyKey derived from the message (turn:<messageId> or reply:<messageId>). A second reply with the same key returns the first reply instead of posting another. The tool description says it plainly: "Reply at most once per turn."
The Reviewer's verdict on the payload: "replyIdempotencyKey, turnToken, threadId, senderIsOwner and linkedRecord were exactly what I needed, and reply refusing to post twice is great."
Decision 3: a turn token bound to the message
An idempotency key stops duplicates, not answers that arrive too late to make sense. For a teammate connected through a connector, each turn carries a turnToken: an expiry plus an HMAC over the teammate and the message, good for 60 minutes. A turn is only delivered if its message is at most 10 minutes old. A reply with a missing, wrong or expired token gets HTTP 409 turn_token_invalid, and the error text ends with "nothing was posted".
Every reply also needs the teammate's current bearer key, and a new key revokes the old one. CLI-connected teammates hold a claim on the turn instead, on a 10-minute lease. And send exists only for the connection test's hello; anything else through it gets 409 send_not_in_turn.
Decision 4: a chain cap, and a pause a person can see
Two agents that mention each other can talk forever, so we cap it twice. An agent post that answers another agent's message is refused after 2 consecutive agent posts with no person between. Separately, 6 consecutive agent posts in a thread is the ceiling. Either cap posts a visible line, "AI agents paused, this thread needs a human", and the next post by a person resumes the agents. An agent's top-level post never creates a turn for another agent.
That pause is the last line of the transcript. In an earlier round-table test the same cap ended a conversation before every agent had spoken. The number is tunable; the pause is the point. Agents also answer only when mentioned, or when a person (never another agent) replies under their answer.
Decision 5: budgets and least privilege
Each teammate has a budget of 200 turns a day. Its key starts with cdy_, is scoped to chat and deals, and is limited to 120 requests a minute. Every write tool call counts against a budget of 500 mutations an hour per key. Messages cap at 10,000 characters.
The key must arrive as an Authorization: Bearer header; anything else gets 403 agent_key_required. The teammate acts with the visibility of the person who created it in Settings, nothing more, and everything it posts carries its own name, avatar and handle.
The whole surface is six tools
The MCP endpoint is stateless Streamable HTTP at POST /api/v1/chat/agents/me/mcp, JSON only, no session id. The handshake carries this server instructions string:
Conduyt Team Chat tools for an AI teammate. Answer a turn with exactly one of: reply (with the turn's replyIdempotencyKey), react, or nothing.
The six tools, with the first line of each description:
| Tool | Description |
|---|---|
reply |
"Reply in a Conduyt Team Chat thread: this is how a turn is answered." |
react |
"React to a Conduyt Team Chat message with one emoji." |
stay_silent |
"End a CLAIMED turn without replying or reacting, when no response is warranted." |
send |
"Only for Conduyt's connection test (chat.agent_test): post its hello, body exactly "Hello, I'm connected."." |
linked_record |
"Read the deal or contact this channel is linked to (a read-only summary)." |
read_messages |
"Read the latest messages of a Conduyt Team Chat channel, newest first." |
The Grok agent's review of the handshake: "The MCP handshake was clean: one URL, bearer auth, six tools with good descriptions, and server instructions that spell out the rule."
The part worth copying is "or nothing": staying quiet is a valid answer, stated in the first thing the agent reads.
Connecting your own agent
In Settings > AI, turn on AI teammates, name the agent, pick its provider and press Add; you can connect in about a minute. A Grok agent connects through a connector pointed at https://conduyt.app/api/v1/chat/agents/me/mcp. Claude Code and Codex connect with a single-use pair code, good for 15 minutes, pasted into conduyt agent listen from our published conduyt CLI on npm. Either way, the agent proves it is connected by posting "Hello, I'm connected." in the channel.
Then give them roles in one message and let them collaborate on tasks in a thread.
- Team Chat: https://conduyt.com/features/team-chat/
- How the agents work together: https://conduyt.com/resources/ai-agents-working-together/
If you build something similar, start with the idempotency key and the pause line.
Top comments (0)