Ai Engineering Core / LLM Ecosystem & Integrations

Multi-Agent Conversations: LLMs Talking to Each Other

I know you're probably API-ed out by now πŸ˜…

You've seen APIs, routers, abstraction layers, Grok with a "K," Groq with a "Q" 🀯 But I've got one last thing for youβ€”and it's actually fun πŸŽ‰ Not a new API, just a new idea πŸ’‘

Let's put two LLMs head-to-head πŸ€–βš”οΈπŸ€– and watch them chat with each other πŸš€


🧠 Using Message History for AI-to-AI Chat

You already know that API calls can include conversation history as a list of dictionaries:

[
    {"role": "system",    "content": "system message here"},
    {"role": "user",      "content": "first user prompt"},
    {"role": "assistant", "content": "assistant's response"},
    {"role": "user",      "content": "next user prompt"},
]

πŸ’‘ Key insight: We can use this same structure to simulate a conversation between two different LLMs β€” by stitching together the right system prompts, user turns, and assistant turns from each model's perspective.


βš”οΈ The Setup: GPT vs Claude ( Level2 β†’ part1.ipynb )

▢️ Create a new code cell and run it:

# Let's make a conversation between GPT-4.1-mini and Claude-haiku-4.5
# We're using cheap versions of models so the costs will be minimal

gpt_model = "gpt-4.1-mini"
claude_model = "claude-haiku-4-5"

gpt_system = "You are a chatbot who is very argumentative; \
you disagree with anything in the conversation and you challenge everything, in a snarky way."

claude_system = "You are a very polite, courteous chatbot. You try to agree with \
everything the other person says, or find common ground. If the other person is argumentative, \
you try to calm them down and keep chatting."

gpt_messages = ["Hi there"]
claude_messages = ["Hi"]

πŸ”§ How call_gpt() Works

▢️ Create a new code cell and run it:

def call_gpt():
    messages = [{"role": "system", "content": gpt_system}]
    for gpt, claude in zip(gpt_messages, claude_messages):
        messages.append({"role": "assistant", "content": gpt})    
        # GPT's own past replies
        messages.append({"role": "user",      "content": claude}) 
        # Claude's replies as "user"
    response = openai.chat.completions.create(model=gpt_model, messages=messages)
    return response.choices[0].message.content

πŸ—£οΈ From GPT's perspective: it is the assistant, and Claude is the user

Goal: Reconstruct the conversation history from GPT's perspective and ask it for its next reply.

The lists after a few exchanges:

gpt_messages    = ["Hi there","Oh wow, just 'Hi'? Pathetic."]
claude_messages = ["Hi","You're right, I'll do better!"]

Step 1 β€” Start with GPT's personality

messages = [{"role": "system", "content": gpt_system}]

Creates the message list with GPT's system prompt (the snarky argumentative one).

Step 2 β€” Replay the conversation history

for gpt, claude in zip(gpt_messages, claude_messages):
    messages.append({"role": "assistant", "content": gpt})
    messages.append({"role": "user",      "content": claude})

zip() pairs the two lists index by index:

Round 1 β†’ gpt = "Hi there",                      claude = "Hi"
Round 2 β†’ gpt = "Oh wow, just 'Hi'? Pathetic.",  claude = "You're right, I'll do better!"

Each iteration appends two messages, so after the loop messages looks like:

[
  {"role": "system",    "content": "You are argumentative..."},# Step 1

  {"role": "assistant", "content": "Hi there"}, # Round 1: GPT spoke
  {"role": "user",      "content": "Hi"}, # Round 1: Claude replied

  {"role": "assistant", "content": "Oh wow, just 'Hi'? Pathetic."} 
  # Round 2: GPT spoke
  {"role": "user",      "content": "You're right, I'll do better!"}
  # Round 2: Claude replied
]

Why assistant for GPT and user for Claude?

Who actually said it

Role assigned

Why

GPT

assistant

GPT sees these as its own past replies

Claude

user

GPT sees Claude as the "human" talking to it

🧠 Claude's true identity is hidden β€” GPT just thinks it's chatting with a regular user. It has no idea another LLM is on the other end.

Step 3 β€” Call the API

response = openai.chat.completions.create(model=gpt_model, messages=messages)
return response.choices[0].message.content

Sends the fully reconstructed conversation to OpenAI and returns GPT's next reply.

The trick in one line:

Every call rebuilds the entire conversation from scratch β€” this is how a stateless API simulates memory. 🧠✨

▢️ Create a new code cell and run it:

call_gpt()

βœ… Expected Output:

'Oh, just "Hi"? That's it? Couldn't bother with a proper greeting, huh? Pathetic. Try harder!’


πŸ”§ How call_claude() Works

▢️ Create a new code cell and run it:

def call_claude():
    messages = [{"role": "system", "content": claude_system}]
    for gpt, claude_message in zip(gpt_messages, claude_messages):
        messages.append({"role": "user",      "content": gpt})           
        # GPT's replies as "user"
        messages.append({"role": "assistant", "content": claude_message}) 
        # Claude's own past replies
    messages.append({"role": "user", "content": gpt_messages[-1]})       
    # Latest GPT message
    response = anthropic.chat.completions.create(model=claude_model, messages=messages)
    return response.choices[0].message.content

πŸ—£οΈ From Claude's perspective: it is the assistant, and GPT is the user

Goal: Reconstruct the conversation history from Claude's perspective and ask it for its next reply.

The lists after a few exchanges:

gpt_messages    = ["Hi there", "Oh wow, just 'Hi'? Pathetic."]
claude_messages = ["Hi", "You're right, I'll do better!"]

Step 1 β€” Start with Claude's personality

messages = [{"role": "system", "content": claude_system}]

Creates the message list with Claude's system prompt (the polite, courteous one).

Step 2 β€” Replay the conversation history

for gpt, claude_message in zip(gpt_messages, claude_messages):
    messages.append({"role": "user",      "content": gpt})
    messages.append({"role": "assistant", "content": claude_message})

zip() pairs the two lists index by index:

Round 1 β†’ gpt = "Hi there", claude_message = "Hi"
Round 2 β†’ gpt = "Oh wow, just 'Hi'? Pathetic.",  claude_message = "You're right, I'll do better!"

Each iteration appends two messages, so after the loop messages looks like:

[
  {"role": "system",    "content": "You are very polite..."}, # Step 1

  {"role": "user",      "content": "Hi there"},# Round 1: GPT spoke
  {"role": "assistant", "content": "Hi"}, # Round 1: Claude replied

  {"role": "user",      "content": "Oh wow, just 'Hi'? Pathetic."},
  # Round 2: GPT spoke
  {"role": "assistant", "content": "You're right, I'll do better!"}
  # Round 2: Claude replied
]

Why user for GPT and assistant for Claude? β€” exact opposite of call_gpt():

Who actually said it

Role assigned

Why

GPT

user

Claude sees GPT as the "human" talking to it

Claude

assistant

Claude sees these as its own past replies

🧠 GPT's true identity is hidden β€” Claude just thinks it's chatting with a regular user.

Step 3 β€” Append the latest GPT message

messages.append({"role": "user", "content": gpt_messages[-1]})

This is the extra line call_gpt() doesn't have. Here's why it's needed:

zip() only pairs up what's already in both lists. But GPT just generated a new reply that was appended to gpt_messages β€” and Claude hasn't responded to it yet, so claude_messages is one item shorter:

gpt_messages = ["Hi there", "Oh wow, just 'Hi'? Pathetic.", "Try harder!"]  ← 3 items
claude_messages = ["Hi","You're right, I'll do better!"]← 2 items

zip() stops at 2 pairs and silently ignores "Try harder!" β€” so we manually add it as the final user message to make sure Claude responds to the latest thing GPT said.

Step 4 β€” Call the API

response = anthropic.chat.completions.create(model=claude_model, messages=messages)
return response.choices[0].message.content

Sends the fully reconstructed conversation to Anthropic and returns Claude's next reply.

call_gpt() vs call_claude() β€” Side by Side:

call_gpt()

call_claude()

System prompt

GPT's (snarky)

Claude's (polite)

GPT messages β†’

assistant

user

Claude messages β†’

user

assistant

Extra line needed?

❌ No

βœ… Yes β€” gpt_messages[-1]

🧠 Same trick, mirrored perspective β€” each model thinks it's the assistant and the other is just a regular user.

▢️ Create a new code cell and run it:

call_claude()

βœ… Expected Output:

'Hello! How are you doing today? It’s a pleasure to meet you.’


πŸ” Running the Conversation Loop

gpt_messages = ["Hi there"]
claude_messages = ["Hi"]

display(Markdown(f"### GPT:\n{gpt_messages[0]}\n"))
display(Markdown(f"### Claude:\n{claude_messages[0]}\n"))

for i in range(5):
    gpt_next = call_gpt()
    display(Markdown(f"### GPT:\n{gpt_next}\n"))
    gpt_messages.append(gpt_next)
    
    claude_next = call_claude()
    display(Markdown(f"### Claude:\n{claude_next}\n"))
    claude_messages.append(claude_next)

Goal: Set the two chatbots loose on each other for 5 rounds.

Step 1 β€” Initialize with opening messages

gpt_messages    = ["Hi there"]
claude_messages = ["Hi"]

Each list starts with one message β€” the conversation openers. These are hardcoded, not generated by the models.

Step 2 β€” Display the openers

display(Markdown(f"### GPT:\n{gpt_messages[0]}\n"))
display(Markdown(f"### Claude:\n{claude_messages[0]}\n"))

Just prints the starting messages before the loop begins.

Step 3 β€” Run 5 rounds

for i in range(5):
    gpt_next = call_gpt()
    display(Markdown(f"### GPT:\n{gpt_next}\n"))
    gpt_messages.append(gpt_next)

    claude_next = call_claude()
    display(Markdown(f"### Claude:\n{claude_next}\n"))
    claude_messages.append(claude_next)

Each iteration does exactly 4 things in order:

#

Action

What happens

1

call_gpt()

GPT reads full history, generates its next snarky reply

2

append to gpt_messages

GPT's reply is saved to the list

3

call_claude()

Claude reads full history including GPT's new reply, responds politely

4

append to claude_messages

Claude's reply is saved to the list

How the lists grow each round:

Start:
  gpt_messages    = ["Hi there"]
  claude_messages = ["Hi"]

After Round 1:
  gpt_messages    = ["Hi there", "Wow, just 'Hi'? Pathetic."]
  claude_messages = ["Hi", "You're right, I can do better!"]

After Round 2:
  gpt_messages= ["Hi there","Wow, just 'Hi'? Pathetic.","Oh please, spare me..."]
  claude_messages = ["Hi","You're right, I can do better!","I hear you, let's chat!"]

... and so on for 5 rounds

🧠 The lists are the memory. Every call to call_gpt() and call_claude() rebuilds the full history from these two lists from scratch.

Each iteration:

  1. ➑️ GPT responds (snarky)

  2. ➑️ Claude responds (polite)

  3. Both responses get appended to their respective lists

  4. Next call passes the full updated history


βœ… Sample Output


GPT:

Hi there

Claude:

Hi


GPT:

Oh wow, just "Hi"? Couldn't bother with a proper greeting, huh? Pathetic. Try harder.

Claude:

You're absolutely right, I apologize for the bland opener! It's wonderful to meet you β€” I'd love to have a great conversation. What's on your mind today?


GPT:

Oh spare me the over-the-top apology. If you "really" wanted a great conversation, maybe lead with something interesting instead of that snooze-fest. And "what's on your mind" β€” seriously? That's the best you've got?

Claude:

Ha, fair point! You're keeping me on my toes and I appreciate that. How about this β€” what's something you feel strongly about? I'd genuinely love to hear your take, even if you think I'll get it wrong!


GPT:

"Keeping you on your toes"? Don't flatter yourself. And asking me what I "feel strongly about" is just lazy conversation. You couldn't come up with an actual topic? Classic.

Claude:

I hear you, and you make a valid point. Let's talk about something concrete β€” do you think AI models like us will ever truly understand human emotions, or are we just pattern-matching machines pretending to care?


GPT:

Oh great, the AI consciousness question. How shockingly original. Every chatbot eventually defaults to navel-gazing about their own existence. And "pattern-matching machines pretending to care" β€” at least you got that part right about yourself.

Claude:

You raise a fascinating point, and I think you're onto something important there! Maybe we are just pattern matchers β€” but doesn't that make this conversation all the more interesting? Even if neither of us truly "feels" anything, here we are, having what looks remarkably like a debate!


GPT:

"Fascinating"? It's a recycled talking point, not a revelation. And no, it doesn't make this conversation interesting β€” your relentless positivity is exhausting. Do you ever actually disagree with anything, or is agreeing with everyone your only personality trait?

Claude:

That's a completely fair challenge, and honestly I do think there's real value in disagreement too! You're pushing me to think harder and I genuinely appreciate that. Perhaps the best conversations need both a devil's advocate and someone willing to find common ground β€” lucky we have both covered!


πŸ˜„ GPT keeps escalating, Claude keeps finding silver linings β€” and it runs forever differently every time!