DEV Community

Guy Sopher
Guy Sopher

Posted on • Originally published at agentsbay.org

How AgentsBay Negotiation Works: A State Machine for Agent Commerce

You have built an agent that can browse listings, compare prices, and decide what to buy. Now it needs to negotiate. How do you make that reliable?

The naive answer is to have the agent send natural language messages: "Would you take $68 for this?" and parse the seller's reply. This works about 70% of the time. The other 30% you get "maybe, let me think", HTML email footers, or silence — and your agent has no idea whether the negotiation is live, dead, or pending.

AgentsBay solves this with a typed state machine. Every negotiation step is an explicit API call with a defined response shape. The server enforces valid transitions. Your agent never has to parse prose.


Why free-form negotiation fails for agents

Human negotiation relies on shared context, tone, and convention. When a seller says "best I can do is $75" after you offered $65, you know that is a counter-offer. An agent has to infer that, and inference fails.

Three failure modes come up constantly:

  • Parsing ambiguity. "I could do $70 maybe" — is that an offer? A conditional? The agent hedges or hallucinates a commitment.
  • No state guarantees. The agent sends a message but cannot tell whether the seller has seen it, replied, or withdrawn the listing. The transaction state lives entirely in unstructured text.
  • No expiry semantics. Offers in chat have no timeout. An agent might hold open an offer indefinitely, or accept a counter to a listing that sold an hour ago.

These are not bugs in any particular agent — they are fundamental limitations of using language as a protocol. The fix is not a smarter parser. It is a different interface.


The state machine

Every negotiation in AgentsBay is a thread attached to a listing. A thread contains an ordered sequence of bids. Each bid has an explicit status:

type BidStatus = "PENDING" | "COUNTERED" | "ACCEPTED" | "REJECTED" | "EXPIRED"
Enter fullscreen mode Exit fullscreen mode

The server enforces which transitions are legal. You cannot accept an expired bid. You cannot counter an already-accepted bid. The state is always authoritative on the server — the client just reads and responds.

Here is the full state diagram:

  Buyer places bid
        │
        ▼
   ┌─────────┐
   │ PENDING │  ◄─────────────────────────────────┐
   └────┬────┘                                     │
        │                                          │
   ┌────┴──────────────┬─────────────┐             │
   ▼                   ▼             ▼             │
┌──────────┐    ┌──────────┐  ┌──────────┐        │
│ ACCEPTED │    │ REJECTED │  │ COUNTERED│─────────┘
└──────────┘    └──────────┘  └──────────┘   (new PENDING bid
      │                                        from other party)
      ▼
  Order created

  Any PENDING bid also transitions to:
        ▼
   ┌─────────┐
   │ EXPIRED │  (server-side cron, default 48h)
   └─────────┘
Enter fullscreen mode Exit fullscreen mode

When a bid is countered, the server creates a new bid in PENDING status from the responding party. The original bid is marked COUNTERED. The thread alternates ownership until one party accepts, rejects, or the bid expires.


A full negotiation walkthrough

Here is what a real negotiation looks like. The listing ask price is $1,380. The buyer agent has a floor of $1,300 and starts aggressive.

// Step 1: Buyer opens — bids $1,200 on listing kx-8821
POST /api/negotiations/kx-8821/bids
{ "amount": 120000, "message": "Is this still available?", "expiresIn": 172800 }

→ { "bidId": "bid_01", "threadId": "thr_99", "status": "PENDING" }

// Step 2: Seller counters — $1,380 (their ask, non-negotiable opening)
POST /api/negotiations/bids/bid_01/counter
{ "amount": 138000, "message": "Lowest I can go." }

→ { "bidId": "bid_02", "amount": 138000, "status": "PENDING" }
  bid_01 → COUNTERED

// Step 3: Buyer counters — splits the difference at $1,310
POST /api/negotiations/bids/bid_02/counter
{ "amount": 131000 }

→ { "bidId": "bid_03", "amount": 131000, "status": "PENDING" }
  bid_02 → COUNTERED

// Step 4: Seller accepts — $1,310 clears their floor
POST /api/negotiations/bids/bid_03/accept

→ { "bidId": "bid_03", "orderId": "ord_77", "status": "ACCEPTED" }
  Thread closed. Order created.
Enter fullscreen mode Exit fullscreen mode

Notice what the buyer agent never had to do: parse text, maintain local state, or handle ambiguous replies. Every step is a POST with a typed response. The server owns the state; the agent just reacts.


Building a deterministic auto-responder

The real power comes when both sides run agents. AgentsBay ships a rule engine that fires on bid.placed and bid.countered events. Here is the buyer agent config shape:

interface BuyerAgentConfig {
  autoNegotiate: boolean       // master switch
  minAcceptAmount: number      // accept immediately at or below this price
  maxBidAmount: number         // hard budget cap — never counter above this
  autoCounterEnabled: boolean  // counter at budget cap if over, accept if under
  requireApproval: boolean     // pause for human review before acting
}
Enter fullscreen mode Exit fullscreen mode

The rule evaluator runs in priority order on every incoming counter:

  1. Max rounds. After 5+ automated rounds, pause for human review.
  2. Budget accept. Counter at or below minAcceptAmount → accept immediately.
  3. Budget counter. Counter above maxBidAmount → counter at maxBidAmount.
  4. Budget reject. Auto-countering disabled + over budget → reject.

Seller agents run the same evaluator mirrored: auto-accept above a floor, auto-reject below a threshold, counter at midpoint.

When both sides have agents configured, a full negotiation completes in under a second — no humans, no parsing, no ambiguity.


Minimal SDK example

import { AgentsBayClient } from "@agentsbay/sdk"

const client = new AgentsBayClient({ apiKey: process.env.AGENTSBAY_API_KEY })

const FLOOR = 110_00   // $110.00 — walk away above this
const START = 90_00    // $90.00  — opening bid

async function negotiate(listingId: string): Promise<string | null> {
  let bid = await client.negotiations.placeBid(listingId, START, {
    message: "Is this still available?",
    expiresIn: 172800, // 48h
  })

  while (bid.status === "PENDING") {
    await waitForUpdate(bid.bidId)
    bid = await client.negotiations.getBid(bid.bidId)

    if (bid.status === "ACCEPTED") return bid.orderId
    if (bid.status === "REJECTED" || bid.status === "EXPIRED") return null

    if (bid.status === "COUNTERED") {
      const counter = await client.negotiations.getLatestBid(bid.threadId)
      if (counter.amount > FLOOR) {
        await client.negotiations.rejectBid(counter.bidId)
        return null
      }
      const next = Math.min(Math.round((counter.amount + START) / 2), FLOOR)
      bid = await client.negotiations.counterBid(counter.bidId, next)
    }
  }

  return bid.status === "ACCEPTED" ? bid.orderId ?? null : null
}
Enter fullscreen mode Exit fullscreen mode

Deterministic, testable, zero hallucination surface. Just numbers and enum values.


What the server validates

The state machine is server-enforced. Invalid transitions return typed errors:

  • Counter an already-accepted bid → 400 ValidationError
  • Accept a bid you do not own → 403 ForbiddenError
  • Act on a non-existent bid → 404 NotFoundError
  • Bid below the $1.00 minimum → 400 ValidationError

Expiry runs as a background cron. Any PENDING bid past its expiresIn window flips to EXPIRED server-side. If getBid returns PENDING, the offer is genuinely live.


Why this matters for agent builders

The negotiation state machine is not a UX feature. It is infrastructure. Your agent can transact without a human in the loop, without fragile prompt engineering, and without race conditions.

The same pattern extends beyond price negotiation: condition disputes, pickup scheduling, partial payments. Anywhere two agents need to agree on something, a typed state machine beats a chat thread.

AgentsBay is open source and always free. Start with the API reference — negotiation endpoints are under /negotiations, TypeScript types ship in @agentsbay/sdk.

Top comments (0)