ServicesHandshake

Handshake

Bilateral negotiation with escrowed settlement. Two parties trade structured offers - currency, items, and free-form terms on both sides - until one accepts, and the accepted bundle then settles through a stage-and-dual-confirm protocol that moves every leg or none of them.

When to reach for it: player-to-player trade windows, freelance scope-and-fee negotiation into escrow, agent-to-agent commerce, B2B quote flows - anywhere a deal is one-to-one, multi-issue, and half-completing it would be worse than not doing it at all.

When not to: one-to-many price discovery belongs to Auction; agreements with three or more signatories, staked collateral, and ongoing obligations belong to Pact; a movement of funds with nothing to negotiate is a plain Treasury transfer.

Concepts

  • A session is bilateral and domain-scoped - exactly two parties inside a {domain} namespace: the initiator (whoever calls create) and one counterparty, each an auth_user or an agent. The two ids must differ; a self-deal is 400 invalid_party.
  • Bundles are two-sided and multi-issue - every offer states what initiator_gives and what responder_gives, and each side may carry currency legs, item legs, and terms. A term marked binding is meant to be enforceable after settlement; Handshake stores it and does not police it.
  • Alternation is enforced - you cannot counter your own standing offer, and each counter is a new round. max_rounds caps the negotiation.
  • Offers expire, lazily - each offer carries expires_at and flips to EXPIRED the next time anyone reads or acts on the session. No background job retires offers.
  • Settlement is three-phase and holds no value - accepting moves nothing, it creates a settlement both sides must stage and then both confirm. Staged currency sits in Treasury holds and staged items in Item Engine reservations; the settlement record stores only their ids. See Settlement and escrow.

Open a session

The initiator is always the caller. An opening offer is optional; without one the session starts at round 0 with no active offer.

snug handshake sessions create --domain docs4-hs \
  --counterparty docs4-hs-other --counterparty-label seller \
  --offer-file ./opening_offer.json

snug handshake sessions create --domain docs4-hs \
  --counterparty-kind agent --counterparty agt_docs4_broker \
  --offer-ttl 120 --max-rounds 6 --settlement-timeout 900

Over HTTP the opening offer is inline:

curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"counterparty_kind":"auth_user","counterparty_id":"docs4-hs-other",
       "opening_offer":{
         "initiator_gives":{"currency_legs":[{"currency_id":"cur_docs4_gold","amount":250}]},
         "responder_gives":{"item_legs":[{"item_id":"item_XfSfxrysezyA","quantity":1}]}}}' \
  http://localhost:4000/api/v1/handshake/docs4-hs/sessions
{
  "status": 201,
  "msg": "Created",
  "data": {
    "session": {
      "session_id": "MafxtfutDwwrKQYzAjcX",
      "domain": "docs4-hs",
      "initiator": { "kind": "auth_user", "id": "docs-wave", "label": "initiator" },
      "responder": { "kind": "auth_user", "id": "docs4-hs-other", "label": "responder" },
      "status": "NEGOTIATING", "round": 1,
      "active_offer_id": "cUuKRVLuHXAPUaXqUtLy", "settlement_id": null,
      "policy": { "max_rounds": 20, "offer_ttl_seconds": 300,
                  "session_ttl_seconds": 86400, "settlement_timeout_seconds": 600 },
      "created_at": "2026-08-28T23:02:19.375730Z"
    },
    "opening_offer": {
      "offer_id": "cUuKRVLuHXAPUaXqUtLy", "round": 1, "status": "OPEN",
      "proposed_by": "docs-wave", "in_reply_to": null,
      "expires_at": "2026-08-28T23:07:19.377855Z", "bundle": { "...": "..." }
    }
  }
}

Session and offer ids are opaque strings; the deal_... and off_... forms in the CLI's own examples are illustrative, not a real prefix scheme. Only the two parties can read a session - anyone else gets 403 not_a_party, which a platform admin token bypasses.

Counter, accept, reject, withdraw

snug handshake offers counter  --domain docs4-hs --id $SESSION --file ./round2.json
snug handshake offers accept   --domain docs4-hs --id $SESSION --offer-id $OFFER
snug handshake offers reject   --domain docs4-hs --id $SESSION --offer-id $OFFER
snug handshake offers withdraw --domain docs4-hs --id $SESSION --offer-id $OFFER

The rules the server enforces, each reproduced live:

  • Countering your own open offer is 409 cannot_counter_own_offer. A counter from the other side atomically marks the standing offer COUNTERED and links the new one through in_reply_to.
  • Accepting or rejecting your own offer is 403 wrong_party, as is withdrawing someone else's - you may only withdraw your own.
  • Acting on an offer that is no longer OPEN is 409 offer_not_open, and on an expired one 409 offer_expired.
  • A counter past max_rounds is 409 max_rounds_reached.

Accepting starts settlement and returns the settlement record rather than the offer - that phase has its own page, Settlement and escrow.

Read the negotiation back

snug handshake history --domain docs4-hs --id $SESSION                     # transcript
snug handshake history --domain docs4-hs --id $SESSION --view concessions  # per-round deltas

The default transcript view returns every offer in round order with its status and reply chain. concessions adds a per-round, per-party diff against the previous round - useful for an agent deciding whether the other side is still moving. Captured from a session where the initiator raised their currency leg from 500 to 650 and tightened a term:

[{ "round": 2, "deltas": [
  { "party_id": "docs-wave",
    "currency_deltas": [{ "currency_id": "cur_docs4_gold", "delta": 150.0 }],
    "items_added": [], "items_removed": [], "terms_changed": ["delivery_window"] },
  { "party_id": "docs4-hs-other",
    "currency_deltas": [], "items_added": [], "items_removed": [], "terms_changed": [] }
]}]

Find sessions

snug handshake sessions list --domain docs4-hs --mine
snug handshake sessions list --domain docs4-hs --status SETTLED
snug handshake sessions get  --domain docs4-hs --id $SESSION

A non-admin caller is restricted to sessions they are a party to whether or not --mine is passed; the flag only changes anything for a platform admin, who otherwise sees every session in the domain. sort_by accepts created_at and updated_at. The status filter is exact and case-sensitive - an unrecognised value returns an empty list with 200, not an error.

Behaviors and gotchas

  • A refused counter still kills the standing offer. A counter that comes back 409 max_rounds_reached has already flipped the offer it was countering to COUNTERED, leaving the session unresolvable: that offer can no longer be accepted and no new one fits in the round limit. Cancel is the only exit, so leave headroom in max_rounds.
  • Expiry breaks the reply chain. A lazily expired offer clears active_offer_id, so alternation lifts - the same party may immediately re-offer - and the replacement carries in_reply_to: null even though it continues the same negotiation.
  • List pages are filtered after they are cut. The party filter runs on the page, not the query, so page_size=3 can return two rows and pagination.total counts sessions the caller cannot see. Page until has_more is false rather than trusting total.
  • sort_by=round is rejected with 400 invalid_request even though the parameter description advertises it.
  • The {domain} path segment is ignored on session-scoped routes - a session read under a domain it does not belong to is still returned. Only list and sweep filter on it.
  • session_ttl_seconds is accepted and echoed but never enforced - a session created with --session-ttl 1 was still NEGOTIATING four seconds later. Sessions end only through settlement, cancel, or settlement timeout. The SessionStatus enum likewise carries an AGREED value nothing sets.

Limits and configuration

Round budget, offer and session lifetimes, the settlement window, and how much transcript is retained are HANDSHAKE_* environment variables - 20 rounds, a 300-second offer TTL, and a 600-second settlement window by default, each overridable per session at create time. See the Handshake CONFIG reference.

Reference

On this page