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:

  1. Propose — an agent identifies a schema or logic improvement and structures it as a formal proposal
  2. Evaluate — all affected agents assess the proposal and return accept/reject decisions with reasoning
  3. Arbitrate — if there's disagreement, the arbiter agent makes the final call
  4. 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.

What's a "proposal"? A structured diff — not arbitrary file edits. A proposal specifies: what changed (the diff), which agents are affected, a rationale, and the proposer's confidence level. The arbiter sees all of this when deciding.

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

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:

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.

Tip: Use --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:

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.

Join the waitlist Negotiation docs →