Message Passing Between Agents: Plain Text vs Structured JSON
Agents talk to each other through the messages your code passes between them. Plain text is fine when a person will read the result or when the next agent is a language model reading prose. Structured JSON is better when code must branch on the result, when fields must be present, or when you want to log and test the exchange. Most real systems use both: JSON envelopes around text content.
Plain text messages
A plain text message is the simplest option. The writer returns a paragraph, the reviewer reads it. This works well for hand-offs where the receiving agent just needs the content. Its weakness is that your code cannot reliably tell what the text means. Is "Looks good, but fix the intro" an approval? You would have to parse prose.
Structured messages
With JSON, you define fields up front: a verdict flag, a list of issues, a confidence score. Your code reads fields directly, with no guessing. Structured messages also make logs searchable and tests precise.
| Need | Use |
|---|---|
| Code decides what happens next | JSON |
| Agent passes long prose to another agent | Text inside a JSON field |
| Human reads the output | Text or markdown |
| You will aggregate or filter results | JSON |
A message envelope
A good habit is wrapping every inter-agent message in the same small envelope. The envelope holds routing and bookkeeping data; the payload holds the content.
import json
import uuid
from dataclasses import dataclass, asdict, field
@dataclass
class Message:
sender: str
recipient: str
kind: str # "task", "result", "feedback", "error"
payload: dict
run_id: str = ""
message_id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
def to_json(self) -> str:
return json.dumps(asdict(self))
task = Message(
sender="orchestrator",
recipient="researcher",
kind="task",
payload={"question": "What is HTTP/2 multiplexing?", "max_words": 100},
run_id="run-001",
)
print(task.to_json())
The output is a single JSON line with all fields, which you can append to a log file. Every message has a sender, recipient and kind, so you can reconstruct who said what to whom.
Getting JSON out of a model
Ask for JSON in the prompt, then parse and validate it. Parsing can fail, so handle that case. A simple approach is to request JSON only, extract the first object, and retry once on failure. The next posts cover schema-enforced output, which removes most of this worry.
import json
import re
import anthropic
client = anthropic.Anthropic()
def ask_json(system: str, user: str, retries: int = 1) -> dict:
for attempt in range(retries + 1):
response = client.messages.create(
model="claude-sonnet-5-5",
max_tokens=2000,
system=system + "\nReply with one JSON object and nothing else.",
messages=[{"role": "user", "content": user}],
)
text = "".join(b.text for b in response.content if b.type == "text")
match = re.search(r"\{.*\}", text, re.DOTALL)
if match:
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
pass
raise ValueError("Agent did not return valid JSON")
verdict = ask_json(
'You review text. Return {"approved": bool, "issues": [str]}.',
"Text: Python was released in 1991 by Guido van Rossum.",
)
print(verdict["approved"], verdict["issues"])
The result is a Python dict. Your orchestrator can write if verdict["approved"]: with no string matching.
Rules of thumb
- Use JSON for control flow, text for content.
- Keep schemas small. Every extra field is another thing the model can get wrong.
- Include a
kindorstatusfield so errors travel as messages instead of exceptions that vanish. - Log the raw message before parsing it. When parsing fails, you will want the original.
Mixing the two in one message
A practical pattern is a JSON envelope with a text body. Suppose a researcher sends findings to a writer. The envelope says who sent it, what the result covers, and how confident the researcher is; the body field holds three paragraphs of notes. The writer's prompt receives the whole thing, but your code can inspect the confidence field and decide to ask for more research before the writer even starts. You get machine-readable control without forcing prose into rigid fields.
Why free text drifts
Free text between agents has a subtle failure: each agent paraphrases. After three hops the original numbers, names or constraints may have shifted slightly, like a game of telephone. Structured fields for the facts that must not change, such as ids, amounts, dates and file names, protect against this. Put the exact values in fields, and let prose carry only the explanation.
Versioning your schema
Add a schema_version number to your envelope as soon as more than one component reads it. When you add a field later, old logs and tests still make sense, and you can write code that handles both versions during a migration. It is a small habit that saves real pain once your system has stored messages.
Comments
No comments yet. Have a question or a tip? Be the first to share it.