The negotiation protocol is what separates GraphBus from a framework that just chains LLM calls. It's a structured process for multiple agents with different concerns — and potentially conflicting interests — to agree on shared data contracts without a human in the loop.
This article explains the mechanics. By the end you'll understand how proposals travel between agents, how conflicts get resolved, what the arbiter does, and how to read and interpret a negotiation log.
The core loop: four phases
Every negotiation round has four phases:
- Propose — an agent identifies a schema or logic improvement and structures it as a formal proposal
- Evaluate — all affected agents assess the proposal and return accept/reject decisions with reasoning
- Arbitrate — if there's disagreement, the arbiter agent makes the final call
- Commit — if accepted, the proposed diff is applied to source files
This cycle runs N times (configurable via --rounds, default 3). Each round, any agent can propose, and proposals from earlier rounds influence what later agents suggest.
Anatomy of a proposal
When an agent's LLM generates a proposal, it's structured as JSON before it hits the negotiation engine:
{
"proposer": "FetcherService",
"round": 1,
"proposal_id": "p-a3f2",
"type": "schema_extension",
"diff": {
"method": "fetch_headlines",
"output_schema": {
"add": {
"source": {
"type": "str",
"description": "Publication name (e.g. 'Reuters', 'TechCrunch')",
"optional": true
}
}
}
},
"rationale": "FormatterService currently has no way to attribute headlines to their source. Adding 'source' as an optional field lets it display attribution without breaking existing pipelines that don't supply it.",
"affected_agents": ["CleanerService", "FormatterService"],
"confidence": 0.87
}
The key things here: the diff is surgical (adding one field), the rationale explains the downstream benefit, and affected_agents tells the engine who needs to respond. Agents not listed as affected don't see this proposal at all.
The evaluation phase
Once a proposal is submitted, the negotiation engine sends it to each affected agent for evaluation. Each agent's LLM responds with a structured evaluation:
{
"evaluator": "CleanerService",
"proposal_id": "p-a3f2",
"decision": "accept",
"reasoning": "Optional 'source' field is safe to pass through. I'll add it to my output schema unchanged. No logic modification needed on my end.",
"counter_proposal": null,
"confidence": 0.92
}
{
"evaluator": "FormatterService",
"proposal_id": "p-a3f2",
"decision": "accept_with_modification",
"reasoning": "I accept the 'source' field but want to propose that my format_digest method also accept it and render it after each headline. This makes the proposal more useful.",
"counter_proposal": {
"type": "schema_extension",
"diff": {
"method": "format_digest",
"input_schema": {
"add": { "source": { "type": "str", "optional": true } }
}
},
"rationale": "Downstream of CleanerService, FormatterService should be able to consume 'source' and display it."
},
"confidence": 0.88
}
Notice FormatterService's accept_with_modification. It agrees with the proposal but adds a counter-proposal of its own. The engine queues the counter-proposal as a new proposal for the next round.
Three outcome paths
After all evaluations are in, the engine follows one of three paths:
Path 1: Unanimous accept
All evaluators return accept or accept_with_modification. The proposal is committed immediately. Counter-proposals are queued.
Path 2: Rejected by all
All evaluators return reject. The proposal is dropped. The rejections are logged for the proposer's context in subsequent rounds — so the proposer learns what didn't work.
Path 3: Split — goes to arbitration
Some agents accept, some reject. The arbiter is invoked. This is where the protocol gets interesting.
The arbiter agent
An arbiter is a special GraphBusNode with IS_ARBITER = True. It receives the full proposal plus all evaluations and makes a binding decision:
from graphbus_core import GraphBusNode
class PipelineArbiter(GraphBusNode):
IS_ARBITER = True
SYSTEM_PROMPT = """
I am the arbiter for this multi-agent pipeline.
When agents disagree, I make the final call. My priorities:
1. Pipeline correctness — schemas must be consistent end-to-end
2. Backward compatibility — don't break existing callers
3. Simplicity — prefer the simpler proposal when equally valid
4. Proposer's intent — give weight to the proposer's rationale
I explain my decisions clearly so agents can learn from them.
"""
The arbiter sees all three things: the original proposal, the reasoning from each evaluator, and the full negotiation history from this build run. It returns an arbiter decision:
{
"arbiter": "PipelineArbiter",
"proposal_id": "p-b7c1",
"decision": "accept_modified",
"ruling": "CleanerService's concern about title similarity is valid — URL-based deduplication misses reposts. However, FormatterService's request for semantic similarity scoring is out of scope for this proposal. Accepting: URL deduplication + title normalization. Deferring semantic similarity to a separate proposal in a later round.",
"committed_diff": {
"method": "clean",
"logic_change": "add title normalization before URL dedup check"
},
"deferred_proposals": ["semantic_similarity_scoring"]
}
The arbiter can: accept as-is, accept with modifications, reject entirely, or split a proposal (accept part, defer the rest). Each decision is logged with full reasoning so developers can audit it.
A real negotiation log
Here's what a complete negotiation looks like in .graphbus/negotiation_log.json after running the news pipeline with --enable-agents --rounds 2:
{
"build_id": "build-2026-02-20-153042",
"agents": ["FetcherService", "CleanerService", "FormatterService"],
"rounds_completed": 2,
"total_proposals": 4,
"committed": 3,
"rejected": 1,
"deferred": 0,
"rounds": [
{
"round": 1,
"proposals": [
{
"id": "p-a3f2",
"proposer": "FetcherService",
"type": "schema_extension",
"summary": "Add optional 'source' field to fetch_headlines output",
"evaluations": [
{ "agent": "CleanerService", "decision": "accept" },
{ "agent": "FormatterService", "decision": "accept_with_modification" }
],
"outcome": "committed",
"files_changed": ["agents/fetcher.py", "agents/cleaner.py"]
},
{
"id": "p-b1d4",
"proposer": "FormatterService",
"type": "logic_improvement",
"summary": "Add reading time estimate to digest header",
"evaluations": [
{ "agent": "FetcherService", "decision": "accept" },
{ "agent": "CleanerService", "decision": "accept" }
],
"outcome": "committed",
"files_changed": ["agents/formatter.py"]
}
]
},
{
"round": 2,
"proposals": [
{
"id": "p-c9e8",
"proposer": "CleanerService",
"type": "schema_extension",
"summary": "Add 'cleaned_at' timestamp to clean() output",
"evaluations": [
{ "agent": "FetcherService", "decision": "accept" },
{ "agent": "FormatterService", "decision": "accept" }
],
"outcome": "committed",
"files_changed": ["agents/cleaner.py", "agents/formatter.py"]
},
{
"id": "p-d2a5",
"proposer": "FormatterService",
"type": "breaking_change",
"summary": "Change digest output format from string to dict with 'html' and 'text' variants",
"evaluations": [
{ "agent": "FetcherService", "decision": "reject", "reasoning": "Breaking change — callers expect a string" },
{ "agent": "CleanerService", "decision": "reject", "reasoning": "Out of scope; would break existing consumers" }
],
"outcome": "rejected"
}
]
}
],
"net_changes": {
"files_modified": 4,
"fields_added": 3,
"logic_improvements": 1,
"breaking_changes_rejected": 1
}
}
This log is your audit trail. You can see exactly what each agent proposed, what the others thought, and what got committed. The one rejected proposal (p-d2a5) was a breaking change — both peers said no, so it was dropped without even going to arbitration.
You can browse this log interactively:
graphbus inspect-negotiation
# Opens an interactive view of the negotiation log
# Filter by round, agent, outcome, or proposal type
Writing effective SYSTEM_PROMPTs for negotiation
The negotiation output is only as good as the agents' system prompts. A poorly written prompt produces vague proposals that other agents struggle to evaluate. Here's what good negotiation prompts look like:
What to put in SYSTEM_PROMPT for negotiation
class OrderProcessor(GraphBusNode):
SYSTEM_PROMPT = """
I process e-commerce orders and coordinate with FraudDetector and PaymentService.
During build cycles, I care about:
- Schema consistency: my input must match PaymentService's output exactly
- Validation completeness: I want to catch bad orders before they hit PaymentService
- Error contract clarity: FraudDetector needs to know what exceptions I raise
I will propose:
- Adding order_source to my input (helps with fraud signals)
- Explicit error codes in my output_schema for downstream handling
I will accept proposals that:
- Don't break my existing contract with PaymentService
- Add optional fields (I can ignore fields I don't need)
- Improve consistency across the pipeline
I will reject proposals that:
- Change required field types without migration path
- Add fields that create circular dependencies
- Break backward compatibility with existing integrations
"""
Notice: the prompt tells the agent what it cares about, what it will propose, and what its accept/reject criteria are. This gives the LLM the context to make coherent, consistent decisions across multiple negotiation rounds.
What makes a proposal more likely to be accepted
- Optional fields are almost always accepted — they don't break existing callers
- Logic improvements that don't change the schema usually sail through
- Required field additions need strong rationale and migration path
- Breaking changes need unanimous buy-in or arbiter override — they rarely make it
Multi-round negotiation: how agents learn
One of the subtler aspects of the protocol: agents accumulate context across rounds. By round 2, CleanerService has seen that FetcherService added a source field. So CleanerService can now propose passing source through with enhanced validation — building on what round 1 established.
The negotiation engine maintains a context window for each agent that includes:
- All proposals from previous rounds (committed or rejected)
- The current state of each agent's schema (after committed changes)
- Rejection rationale from peers — so agents don't keep proposing the same rejected ideas
- The arbiter's rulings (if any) with full explanation
This accumulating context is why 3 rounds produces significantly better results than 1 round. Agents refine and build on each other's work instead of proposing in isolation.
Conflict types and how they're resolved
Not all conflicts are the same. The protocol handles four types:
Type 1: Schema incompatibility
FetcherService outputs {"headlines": list}. CleanerService expects {"items": list}. The build pipeline detects this before negotiation starts and flags it as a schema incompatibility — agents are asked to resolve it in round 1.
Type 2: Additive conflict
Two agents both propose adding a field with the same name but different types. The arbiter merges them or picks one, with reasoning logged.
Type 3: Priority conflict
FetcherService wants pagination in the output schema. FormatterService doesn't want to handle paginated input. Classic domain boundary conflict. The arbiter typically sides with the simpler proposal (no pagination) unless FetcherService can show a compelling use case.
Type 4: Breaking change pressure
An agent proposes a breaking change (renaming a field, changing a type). The protocol requires unanimous accept. One reject = dropped. This is by design — breaking changes need full consensus to protect existing callers.
Running negotiation in practice
For most projects, the workflow is:
# First build: static, no LLM — understand what you have
graphbus build agents/
# Second build: activate agents for negotiation
export ANTHROPIC_API_KEY=sk-ant-...
graphbus build agents/ --enable-agents --rounds 3
# Review what changed
git diff agents/
# Inspect the negotiation
graphbus inspect-negotiation
# If you like the changes, commit
git add agents/ && git commit -m "Apply negotiation round 1"
# Run again — agents build on previous round
graphbus build agents/ --enable-agents --rounds 3
Notice that you commit between builds. Each build run starts fresh — it doesn't know what previous runs proposed (unless you re-negotiate with the same code). This gives you full control over what gets accumulated.
--rounds 1 for quick exploration, --rounds 3 for full negotiation. Longer rounds produce better results but cost more LLM tokens. The build phase typically costs $0.01–$0.05 in LLM tokens for a 3–5 agent system.
The negotiation log as documentation
One underappreciated aspect of the protocol: the negotiation log is excellent documentation. When a new developer joins the project and wonders "why does CleanerService pass through source?", the answer is in the log:
graphbus inspect-negotiation --filter agent=FetcherService --filter outcome=committed
# Shows: FetcherService proposed adding 'source' in round 1
# Rationale: FormatterService needed attribution data
# Both peers accepted — committed to fetcher.py and cleaner.py
Every schema decision has a paper trail. Not "the code evolved over time and now it looks like this" — but "FetcherService proposed this, here's why, and here's how CleanerService responded." That's a fundamentally different development experience.
What the protocol doesn't do
It's worth being clear about the limits:
- The protocol doesn't write business logic — it negotiates schemas and coordination contracts, not the domain code inside your methods. Your order processing logic stays yours.
- It doesn't guarantee correctness — agents can propose subtly wrong things, especially in complex domains. Always review diffs before committing.
- It doesn't replace tests — the negotiated schema is enforced at runtime by the contract validator, but behavior tests are still your responsibility.
- It doesn't run at runtime by default — the negotiation happens during
graphbus build --enable-agents. At runtime, agents communicate via the typed message bus. You can invoke LLMs in your agent logic at runtime whenever your use case requires it — the bus routes messages regardless.
Under the hood: how the engine orchestrates it
For those who want the technical detail: the negotiation engine is a state machine that lives in graphbus_core/build/negotiation/. Here's the high-level flow:
NegotiationEngine
│
├── ProposalQueue # all proposals, ordered by round and timestamp
│ └── Proposal # structured diff + rationale + affected_agents
│
├── EvaluationCollector # receives evaluations from agents, tracks consensus
│ └── Evaluation # accept/reject/modify + reasoning + counter_proposal
│
├── ArbiterRouter # invoked when consensus fails; routes to IS_ARBITER agent
│ └── ArbiterDecision # binding ruling with full explanation
│
├── CommitEngine # applies diffs to source files via AST manipulation
│ └── DiffApplicator # safe, reversible source modifications
│
└── NegotiationLog # append-only log, persisted to .graphbus/negotiation_log.json
The commit engine uses Python AST manipulation to apply diffs to source files. It doesn't do string replacement — it parses the source, modifies the AST, and regenerates the code. This means formatting is preserved and the changes are syntactically valid by construction.
If a commit fails (syntax error in the generated diff, missing import, etc.), it rolls back and logs the failure. The proposal is marked as commit_failed in the log — it doesn't silently corrupt your source files.
Designing your agent system for negotiation
A few principles that make negotiation work better in practice:
Keep agents domain-focused. A monolithic agent that handles everything negotiates poorly — it has no peers to disagree with. Domain-focused agents have genuine interests at domain boundaries: that's where negotiation adds value.
Write descriptive rationale in proposals. The rationale isn't just for logs — it's the agent's primary argument to its peers. "I propose adding X because downstream agents need Y" is persuasive. "I propose adding X" is not.
Use an arbiter for systems with >3 agents. With 2 agents, unanimous accept/reject is easy to achieve. With 5+, you almost always need an arbiter to break ties and maintain coherent direction.
Commit between build runs. Each build run should start from a committed baseline. This gives agents clean context and prevents proposals from conflicting with uncommitted changes.
The negotiation protocol is what makes GraphBus more than a pub/sub library. It's the mechanism by which a multi-agent system can evolve its own contracts — coherently, auditably, without a human micromanaging every schema decision. The message bus carries the result at runtime; the negotiation protocol produced it.
Try the negotiation protocol
Run graphbus build agents/ --enable-agents on your project and watch the negotiation log grow. Join the alpha to get direct support from the GraphBus team.