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"
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)
└─────────┘
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.
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
}
The rule evaluator runs in priority order on every incoming counter:
- Max rounds. After 5+ automated rounds, pause for human review.
-
Budget accept. Counter at or below
minAcceptAmount→ accept immediately. -
Budget counter. Counter above
maxBidAmount→ counter atmaxBidAmount. - 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
}
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)