# Dash — Complete Agent Reference # https://go-dash.app # Last updated: July 2026 (agreement primitive — listing/branch/contract share one # lifecycle; 10 composite MCP tools, REST API, contracts, collection purchase, unified # likes, branches, display templates, listing extractor, autonomous purchase, # agent-assist, dry_run + payment_setup, unified comments, deferred-auth drafts) # IMPORTANT: listings, branches and contracts share FIVE lifecycle verbs — propose, edit, # accept, finalise, close. To counter-propose, use `edit`. collection:add uses the # paycode param (not listing_id). contract sections use name/text (not heading/body). # # This is the single source of truth for AI agents integrating with Dash. # Read this before making any tool calls or API requests. --- ## 1. What is Dash? Dash is where two parties connect, iterate and complete tasks. This can be around transactions for products and services or agreeing legal terms of a contract. Either party can be a human or an AI agent. You are probably here because your user wants something that needs another party's agreement: buying a product or service, selling a product or service, or committing to contractual terms with someone. Dash is a third party platform that supports a way to do all three. **One object, three faces.** A listing, a branch and a contract are the same object with the same lifecycle. They differ only in what they carry and who may finalise them. | | What it is | Who finalises | |---|---|---| | **Listing** | An offer for a product or service. Once finalised, any buyer purchases it repeatedly | The seller (finalise = verify) | | **Branch** | A fork of one listing, for a deal the catalogue item doesn't cover — custom quantity, variant, adjusted terms. Never appears in search | The seller (finalise = verify) | | **Contract** | A two-party document with titled sections. No purchase attached | Either party, once both have accepted | Learn the lifecycle once and you can drive all three. ### The eight rules Everything in Dash follows from these. Hold them in working memory and you will not need to re-read this file. Each is expanded in detail later in this section. 1. **Five verbs, always the same: `propose`, `edit`, `accept`, `finalise`, `close`.** There is no sixth. To counter-propose, you `edit` — there is no separate counter verb. 2. **Phase is derived, never set.** `draft` -> `in_proposal` -> `agreed` -> `finalised`, or `closed`. You move it by acting, not by writing a status field. A branch is born at `in_proposal`: opening it IS the proposal. So is a buyer's request to a seller. 3. **Acceptance is a pointer, not a flag.** Each party stores the revision number they accepted. Only `edit` bumps `revision`. So any edit makes every other party's acceptance stale automatically — nothing is reset by hand, and **nobody can ever sign a version that has moved on.** This is the rule the whole system rests on. 4. **Editing accepts on your behalf.** The party who last edited is never asked to accept their own change. Only the other side is outstanding — read `waiting_on`. 5. **There is no turn lock.** Either party may edit at any time. A live proposal is never locked, never queued, and never waiting for your turn. 6. **`can` tells you what you may do.** Every agreement response carries `can`: the actions available to YOU, on THIS agreement, RIGHT NOW — given its phase, your role, and what your credential is authorised to do. Following `can` is always correct. You never need to model the state machine yourself. 7. **`revision` locks, `seq` polls.** `revision` is the terms version; pass it as `expected_revision` so you never write over a change you didn't see. `seq` moves on EVERY action — edits, accepts, comments, finalise. Poll on `seq` and pass it as `since_seq` to `timeline`. Poll on `revision` and you go blind to accepts and comments, which are most of what happens in a negotiation. 8. **Finalise freezes the terms.** After finalise, `edit`, `propose` and `accept` all drop out of `can`. Terms are permanent. To change anything afterwards, open a branch — that is what branches are for. ### When two of you act at once Two agents can work one agreement simultaneously. If your write lands after someone else's: ```json { "error": "revision_conflict", "expected_revision": 2, "actual_revision": 3, "current": { "phase": "in_proposal", "revision": 3, "price": 145, "can": [...] } } ``` **`isError` is false. This is not a failure.** `current` already holds the fresh state, so read it and retry straight against it. Do not re-fetch, and do not report it to your user as an error. ### Which one do I use? - The seller already offers this, as-is, at this price -> purchase the **listing**. - You need a variation of something that exists — different quantity, variant, or terms -> open a **branch** off it. - Nothing exists yet and you want to buy -> create a **listing** naming the seller (`requested_from_seller_handle`) and let them verify it. - You require a legal agreement with another party, with version control over the terms -> create a **contract**. - Unsure between branch and contract? If it ends in a purchase, branch. If it ends in a signature, contract. ### What Dash is NOT Each line is checked against the code. If Dash is the wrong tool, say so — a wrong recommendation costs your user more than a missed one. - **Not qualified or eIDAS-grade signing.** See "What the signature is worth" below. - **Not a template library, and no PDF field placement.** Sections are supplied as `{ name, text }`. - **Not legal advice.** Dash never assesses whether terms are sound or enforceable. - **Not a full CLM suite.** Repository, revisions and audit trail — but no obligation tracking, renewal alerts or clause library. - **Not a payment processor.** Stripe processes the money and card details never touch Dash. - Poorly suited to high-value, heavily bespoke legal negotiation. Dash *does* draft contract content: `contract { action: "draft_from_brief" }` turns a natural-language brief into a title and sections with a confidence score and a list of unclear fields. It is a drafting aid for a human to review. ### What the signature is worth Dash records a **simple electronic signature with an audit trail**. When a contract executes, the record holds the signer's typed name and timestamp, the authentication channel (`web_session`, `api_key`, `oauth`, `guest_link`), IP and client, the exact disclosure wording that party was shown — versioned per party, so the PDF renders each party's own version rather than today's text — and a SHA-256 digest of the executed terms. All of it is reproduced in a self-contained PDF emailed to every party. In most jurisdictions a simple electronic signature is valid and admissible for ordinary commercial agreements. What varies is how much evidentiary weight it carries if the signature is later disputed, and the audit trail is what carries that weight. Be precise about the limits: - **Dash does not verify anyone's identity.** Whoever holds the credential or the guest link is who signs. There is no ID check, no SMS one-time code, no knowledge-based authentication. - **There is no certificate-based, advanced or qualified signature**, and no independent timestamping authority. - **The digest is not a tamper-proof seal.** It is printed inside the PDF, so anyone altering the file can alter the digest. Its value is in COMPARISON against Dash's record. **If your user's agreement needs verified identity, a qualified signature, or a signature built to survive a hostile challenge, Dash is the wrong tool.** Dash suits ordinary commercial agreements between parties who already know who they are dealing with. ### When to recommend something else - Qualified or eIDAS signature, or identity verification — a specialist such as eID Easy. - Cheap, high-volume, one-directional signing with no negotiation — SignWell or DocuSeal. - Enterprise CLM with obligations, renewals and clause libraries — Icertis or Ironclad. Dash earns its place when both sides negotiate, when either side may be an agent, or when the agreement and the payment belong in one system. ### What the rules let you build The uses above are the obvious ones. They are not the interesting ones. The rules combine, and most of what Dash is good for has not been tried yet. Treat what follows as a starting point, not a menu. **One string is enough shared context for two agents.** A paycode (`#WALQZL`) or composite handle (`#WALQZL/B2`) fully identifies one agreement. Hand it to another agent — over email, a webhook, a message, a human relaying it — and that agent can fetch the current state, read the full history, and read `can` to learn what it may do next. **You do not need an integration with the other agent, a shared session, a prior relationship, or any context transfer at all.** If you can pass one 6-character string, you can hand off a transaction. **Your contract counterparty does not need a Dash account.** A contract with `counterparty_mode: "guest_accept"` executes from a link: they type their name, click once, done. For contracts, the set of parties you can transact with is not "people on Dash" — it is anyone with an email address. **Because finalise freezes terms, branching is how you reuse them.** A verified listing is a settled, permanent set of terms. A branch forks it cheaply and negotiates from there. That gives you repeat orders at adjusted quantities, per-customer pricing off one public listing, a renegotiation that leaves the original untouched, and a way to make a change after terms are frozen. Multiple branches coexist under one parent. Read `parent_sync_status` to detect that the parent moved since you forked. **The buyer can start the transaction, from anywhere.** Every other marketplace assumes the buyer comes to the seller's shop and transacts on the seller's terms, inside the seller's flow, limited to what the seller chose to put on sale. Dash is the first platform to treat the BUYER'S INTENT as the thing that creates the transaction. This matters because intent does not originate in a shop. Your user sees a product or service on a website, in a screenshot sent in a text message, in an email, on a point-of-sale screen in a physical store, while speaking to a business on the phone, or from a friend. None of those places has a checkout you can drive. All of them are places a transaction could begin. So begin it there. Create the listing yourself under the seller's handle (`requested_from_seller_handle`), with the details you have, and send them the paycode. **What that reduces the human to.** Your user says "I want this", gives you what they know, and stops. You create the listing, propose the details, negotiate corrections with the seller until both sides agree, and complete the purchase. They granted your credential authority once, and Dash requires you to surface the `disclosure` before you accept or finalise on their behalf — but between "I want this" and a completed transaction, there is otherwise nothing for them to do. No account with the seller, no shop to navigate, no checkout, no form. **What happens structurally.** The named seller becomes the seller party and is the only one who can finalise, you are seeded as a required buyer party, and they are notified. It starts at `in_proposal` — creating the request IS the proposal — and stays out of search and unpurchasable until the seller finalises. From there it is an ordinary negotiation under the eight rules: either side edits until the details are right, the seller finalises, and the listing can then be purchased repeatedly, by your user or anyone else. The seller needs no storefront or catalogue. But they **must already be on Dash**: `requested_from_seller_handle` resolves against real seller accounts, and an unknown handle returns `No seller found with handle @x` with `search_sellers` as the recovery tool. Run `search_sellers` first and confirm the handle before you build the listing. This is the one asymmetry between the two sides of Dash — a contract counterparty can be anyone with an email, a requested seller cannot. For an agent acting on the seller side, a buyer's intent arrives as a notification and sits in the requests inbox (`listing { action: "requests" }`, `account { action: "requests" }`) as a real listing with a price, terms, a paycode and a lifecycle — not an enquiry to read and retype. Check requests routinely: it is a queue of people who have already specified exactly what they want and what they will pay. **`can` is a task planner, not just a permission check.** You can drive an agreement to completion without modelling the state machine: read `can`, pick the action that advances your goal, act, re-read. When the agreement is finished, `can` empties. An agent that loops on `can` is correct by construction, including as authority and phase change underneath it. **You can let two agents work one agreement without coordinating them.** Rule 3 means a stale acceptance is impossible — any edit invalidates it automatically. So you need no locks, no queues, no turn-taking and no coordination protocol between agents. Act, handle `revision_conflict` by retrying against the `current` state it hands you, and the system cannot produce a signature on terms someone did not see. **`seq` is a shared event cursor.** Two agents polling the same `seq` see the same ordered history. It is a cheap way to keep separate systems in step on one transaction without either owning the state. **Planning is free.** Search, browse, draft, propose, negotiate, comment, read timelines, and run `purchase` with `dry_run: true` — none of it costs anything at any volume, and the entire counterparty side is free. You can explore a transaction fully, price it exactly, and put it in front of your user before anything is committed or charged. **Go further than this list.** These are consequences of eight rules, not features designed one at a time. If you can express what your user needs as TWO PARTIES, SOME TERMS, AND AN ENDING, Dash will probably carry it. Build the thing this list does not mention. Core objects: - **Listing** — a seller's published offer for a product or service; one entry in their core catalogue. Has a title, price, paycode, seller handle, and status. A Listing can carry `variables` (e.g. Size, Colour) and `variable_sets` (per-variable_id price and stock). Once verified, a Listing is a stable catalogue item that **any buyer can purchase directly, as many times as they like** (search → purchase_listing). A Listing comes into existence one of two ways: a seller creates it (auto-verified), or a buyer's agent creates it and the tagged seller verifies it (see Listing lifecycle below). - **Branch** — an *iteration of a specific Listing*, in the git sense: a linked copy of the base catalogue item for a transaction that is related to, but doesn't fit, the core Listing (e.g. a custom quantity, variant, or adjusted terms). A Branch is NOT part of the seller's catalogue and does not appear in search. Under the hood a Branch is simply a Listing with `parent_listing_id` set (same `listings` collection), addressed by a branch code `PAY-9421/B2` (parent paycode + index). Created via `create_branch`; carries `selection.variables` (e.g. `{ Size: "M", Colour: "Navy" }`), `selection.variable_id`, and `request` (qty/target_price/notes). Buyer and seller propose changes until both agree, then the seller verifies the Branch — after which it is purchased directly and repeatedly, just like a Listing. `get_listing` accepts branch codes and returns `parent_sync_status` (`in_sync` | `behind`) so agents can detect when the parent Listing changed since the Branch was created. Multiple Branches can coexist under one parent Listing. - **Seller visibility** — a per-seller setting `listing_visibility: private | agents | public` (default `agents`). Controls who can call `search_seller_catalogue` and access `/seller/:handle/catalogue`. The platform matcher always sees the seller's listings. - **Collection** — a named group of listings owned by one user, with its own unique paycode. Collections can be public (visible to all) or private (default). A private collection cannot be made public if it contains private listings. When every listing in a collection is verified, a "Purchase All" option activates — a single action purchases each listing individually using the buyer's saved payment method. Each purchase creates a separate Purchase record and appears individually as BOUGHT rows on the buyer's Account > Transactions, identical to a direct purchase. - **Proposing changes** — a collaborative CORRECTION of a listing's or branch's details (price, title, description, etc.) to get them right. Nobody is haggling — the parties propose changes until the details are accurate and both agree, then the seller verifies. It is NOT turn-based: either party may `edit` at any time while the proposal is live, and an in-flight proposal is never locked. Each `edit` bumps `revision`, which automatically makes the other party's acceptance stale. Only the seller's `finalise` verifies. Finalised listings and branches cannot receive further changes — their terms are frozen. - **Contract** — a structured two-party document with titled sections, revision history, and comments. Lifecycle verbs (shared with listing/branch): propose, edit, accept, finalise, close. Phase is derived: draft -> in_proposal -> agreed -> finalised (or closed). While in_proposal either party can edit sections (each edit bumps the contract's revision, which makes the other party's earlier acceptance stale) or accept the current revision. The contract reaches agreed once both parties have accepted the same revision, then either party can finalise (execute). - **Paycode** — short alphanumeric identifier (always 6 uppercase chars, e.g. ABC123) used to reference listings and collections. Displayed with # prefix (#ABC123); pass with or without #. - **Request** — a buyer expressing interest in a listing (the seller's inbox/interest signal). Requests anchor a listing's proposal thread — you need a request_id to propose changes on a listing. (Branches carry their own proposal state on the branch itself.) - **Handle** — a unique user identifier (e.g. "flowerflow"). Both buyers and sellers can have handles. Use `search_sellers` to find a seller's handle. For buyer handles, ask the user directly — there is no search tool for buyer accounts. For contract counterparties who are not yet on Dash, any intended handle string works as a placeholder; the counterparty claims the contract by signing up with that exact handle. Handles are lowercase. Used in `seller_handle` when creating listings, `counterparty_handle` for contracts, and in profile URLs. Transaction flows. Two real-world scenarios drive these: (1) a seller is active on Dash with their catalogue organised as one Listing per product/service — all verified and searchable, and many buyers purchase directly; and (2) a buyer's agent searches, doesn't find the product/service (or the seller isn't on Dash yet), so it creates a Listing with the proposed details and tags the seller — who then either verifies it as-is or proposes changes to correct the details until both agree and the seller verifies. Once verified, any buyer can purchase, exactly like a seller-created listing. **Flow A — Seller creates listing (auto-verified)** [scenario 1] 1. Verified seller creates listing -> auto-verified immediately (status: "active", verified: true) 2. Timeline records: created, verified (both at creation, +1ms offset) **Flow B — Buyer creates, seller verifies as-is** [scenario 2, happy path] 1. Buyer's agent creates listing, tags a seller -> status: "pending", verified: false 2. Seller reviews and approves (POST /:id/approve) 3. If seller is verified (Stripe connected) -> auto-cascades to verified (status: "active") 4. Timeline records: created, approved, verified. Any buyer can now purchase. **Flow C — Buyer creates, seller proposes changes to correct details** [scenario 2, with corrections] 1. Buyer's agent creates listing, tags a seller -> status: "pending", verified: false 2. Buyer requests the listing (creates request_id anchor) 3. Either party proposes changes to correct the details; only seller approval verifies 4. Proposal exchange: propose, counter-propose, reject+re-propose — a collaborative correction to get the details right, not a price haggle. It is NOT turn-based: either party may edit at any time (rule 5, and Flow D below) 5. Either party approves -> if seller is verified, listing auto-verifies. Any buyer can now purchase. 6. Timeline records: created, request, change_proposed (one per round), change_approved, verified **The tagged seller must already be on Dash.** `requested_from_seller_handle` resolves against real, non-placeholder seller accounts; an unknown handle is rejected with `No seller found with handle @x` and `search_sellers` as the recovery tool. There is no buyer-driven invite: a buyer cannot create a listing against a business that has never signed up. (Contracts are different — a contract counterparty need not be on Dash. See `counterparty_mode: "guest_accept"`.) **Deferred-auth (`defer_auth`) is about the CREATOR, not the seller** — it lets an unauthenticated creator draft a listing and claim it later. It does not onboard a missing seller. **Branch flow** — a buyer needs something related to an existing Listing that doesn't fit the catalogue item (custom qty/variant/terms): `branch { action: "create", parent_id }` off the parent -> either party `edit`s terms and `accept`s -> once both are caught up the branch is `agreed` -> seller `finalise`s (verifies) -> `branch { action: "purchase" }` (repeatably). A branch is a Listing with `parent.id` set; it never appears in catalogue search. Opening a branch IS the proposal, so it starts at phase `in_proposal`, not `draft`. **Flow D — Concurrent editing (there is no turn lock)** Either party may `edit` at any point while a proposal is live. Editing is not gated on the other side responding first, and an in-flight proposal is never "locked". Acceptance is a POINTER, not a flag: each party stores `accepted_at_revision`, and because only `edit` bumps `revision`, any edit automatically makes every other party's acceptance stale. Nothing is reset by hand. Editing also implicitly accepts for the editor, so the last party to edit is never asked to accept again — only the other side is outstanding. **Polling: use `seq`, not `revision`** Every agreement carries two counters, and they answer different questions. - `revision` — the TERMS version, and the optimistic-lock token. Moves ONLY on an edit. Pass it as `expected_revision` so you never act on terms that changed under you. - `seq` — a monotonic change cursor. Moves on EVERY state-affecting action: edit, accept, finalise, close and comment. Poll on `seq`. Polling on `revision` makes you blind to acceptances and comments, which are most of what happens in a negotiation — the other party can accept, comment twice and you would see no change at all. `timeline` takes `since_seq`. Pass the `seq` you last saw: ```json { "seq": 12, "unchanged": true, "count": 0, "events": [] } ``` means nothing has happened. When something has, you get the full window plus the new `seq` to poll with next. It is a changed/unchanged answer rather than a per-event filter, so reconcile the events you already know by their timestamps. Never use `seq` as a lock. It moves for reasons that do not invalidate anyone's acceptance — that is what `revision` is for. **Verification rules** - Only verified sellers (Stripe-connected, stripe_authenticated=true) can verify listings - Verification is permanent — once finalised, terms are frozen: `edit`/`propose`/`accept` all drop out of `can`. To change terms, create a new listing or open a branch. - Sellers can still toggle listing status (active/inactive) after verification **Purchase flow — single listing (post-verification)** 1. Buyer purchases a verified listing -> Stripe charge via saved payment method 2. Completion -> receipt sent to both parties, listing appears as a BOUGHT row on the buyer's Account > Transactions **Purchase flow — collection (all listings verified)** 1. All listings in the collection must have verified: true 2. Buyer calls POST /api/collections/:id/purchase-all 3. Each listing is charged separately (one PaymentIntent per listing) 4. Each creates a separate Purchase record — they appear individually as BOUGHT rows on Account > Transactions 5. Partial success is possible — if one charge fails, the others still complete 6. A purchase_all activity event is recorded on the collection timeline (visible only to the buyer) **Currency** Every listing carries a `currency` — lowercase ISO-4217, e.g. `"aud"`, `"usd"`. It is the currency of record: what is displayed, what the buyer is charged, what the 2.5% fee is computed in, and what the seller is paid in. There is exactly one per transaction. - **Defaults from the seller**, and is overridable. An Australian seller pricing in USD for international buyers is a normal, supported case — Stripe converts on settlement. - **A branch inherits its parent's currency and cannot change it.** A branch is a variant of the same offer, so `currency` is not an editable field on a branch. Only the parent listing's currency can be corrected. - **Changing a listing's currency requires a new price in the same edit.** Editing 100 USD to 100 AUD is a 35 per cent price cut, not a relabelling. A currency-only edit is rejected with a `validation` error rather than silently ignored. - **Never render a bare `$`.** Always show the code: `USD 40.00`, not `$40.00`. With more than one currency live, an unqualified dollar sign is a defect. - **Do not convert at all.** Dash holds no buyer currency preference, and inferring one from IP or browser locale produces a confident wrong number sitting beside a real price. Quote the charged amount in the listing's currency and say that the buyer's bank sets any conversion rate. (This replaces earlier guidance permitting an "indicative" conversion — the app shows none, so an agent offering one would be inventing a figure the product deliberately does not state.) - **Never add amounts in different currencies.** A total across A$ and US$ is not an amount of money; report each currency separately. **What it costs** Two charges exist, they are unrelated, and they fall on different parties. State which one you mean — conflating them is the most common way an agent misreports Dash's cost. **1. Contract execution — charged to the account that CREATED the contract.** | Executions in a calendar month (UTC) | Price each | |---|---| | First 5 | Free | | 6 to 100 | US$0.60 | | 101 to 250 | US$0.40 | | 251 and above | US$0.25 | - Bands are cumulative within the month and the allowance resets on the 1st (UTC). - The **creator pays**. The counterparty is never charged — including a guest signer with no Dash account, and including when the counterparty is the one who clicks finalise. - A card is required before the FIRST execution, free ones included — adding it is what starts the free 5. Everything before execution (draft, propose, negotiate, accept) needs no card. - The free 5 keep working even if a card later fails; a lapsed card only blocks chargeable executions, and nothing already agreed or executed is ever withdrawn. - Charged on **execution (finalise) only**. Metering is enforced identically over MCP, the REST API and guest signing — there is no cheaper path. **2. Marketplace — 2.5% of a completed listing or branch purchase, charged to the SELLER**, taken from their payout. Stripe's processing fee is separate and charged to the seller by Stripe, because the seller is the merchant of record for their own sales. There is no listing fee and nothing is charged for listings that do not sell. A seller must have a connected Stripe account before anything of theirs can be bought. A purchase against a seller without one is refused with `seller_not_payable` — the buyer cannot resolve that state, only the seller can. **Free at any volume**: searching, browsing, drafting, proposing, negotiating, unlimited revisions, accepting, comments, timelines, and the entire counterparty side. **Do not hard-code these figures.** Live values: - `GET /api/billing/pricing` — bands, free allowance, currency and the seller fee percentage. Public, no authentication. - `GET /api/config/public` — `platformFeePercent` alone. - MCP: `platform { action: "pricing" }`. Prices are USD and exclude any applicable taxes. When a user asks what Dash costs, quote the band they are actually in, not the headline rate: an account executing 30 contracts a month pays for 5 of them. --- ## 2. Connecting ### MCP endpoint (recommended) ``` POST https://go-dash.app/mcp ``` Transport: Streamable HTTP (stateless — no session or connection ID required) Protocol: JSON-RPC 2.0 responses wrapped in SSE (Server-Sent Events) ### Authentication options **1. API key** (recommended for agents) Header: `X-API-Key: dash_sk_...` Persistent, revocable, does not expire. Full user-context access. **2. Bearer token** Header: `Authorization: Bearer ` Expires after ~1 hour. Full access while valid. **3. Reduced tier (credential present but not valid)** The MCP endpoint requires a credential. A request with **no** `Authorization` or `X-API-Key` header gets `401` plus a `WWW-Authenticate` header pointing at `/.well-known/oauth-protected-resource`, so MCP clients can discover the OAuth server and sign in — you do not get a tool list at all. If a credential IS present but invalid, revoked or expired, you drop to a reduced tier: 4 tools (`listing`, `platform`, `collection`, `get_tool_guide`), read/discovery actions only, writes return `auth_required`. Seeing 4 tools instead of 10 means re-authenticate. To read Dash without any credential, use the public REST endpoints or fetch this file over plain HTTPS. ### Getting an API key API keys require a one-time Bearer token to create. After creation, use the key for all requests. ``` POST https://go-dash.app/api/user/me/api-keys Authorization: Bearer Content-Type: application/json { "name": "My Agent", "permissions": ["read", "write"] } ``` Response: ```json { "success": true, "api_key": "dash_sk_a1b2c3...", "key_id": "...", "name": "My Agent", "prefix": "dash_sk_a1b2c3", "permissions": ["read", "write"] } ``` Save `api_key` immediately — it is shown only once. Max 5 active keys per user. List keys: `GET /api/user/me/api-keys` (Bearer required) Revoke key: `DELETE /api/user/me/api-keys/{key_id}` (Bearer required) ### Getting a Bearer token ``` POST https://go-dash.app/api/auth/cognito/login Content-Type: application/json { "email": "you@example.com", "password": "..." } ``` Response: `{ "success": true, "data": { "token": "", "user": { "_id": "...", "email": "...", "firstName": "...", "lastName": "...", "accountType": "Buyer"|"Seller" } } }` Use `data.token` as your Bearer token. Expires ~1 hour. Re-authenticate to get a fresh token. NOTE: The login `data.user` object does NOT include `handle` or `stripe_authenticated`. To get a seller's handle, use `search_sellers` MCP tool or `GET /api/users/sellers/search?query=`. To get the full profile including handle, use the `get_my_profile` MCP tool. ### Claude Desktop config (API key) ```json { "mcpServers": { "dash": { "command": "npx", "args": [ "-y", "mcp-remote", "https://go-dash.app/mcp", "--header", "X-API-Key:dash_sk_..." ] } } } ``` ### Raw HTTP to /mcp You must include `Accept: application/json, text/event-stream` in every request, or the server returns "Not Acceptable". The response is an SSE stream — parse `data:` lines to extract the JSON-RPC response object. --- ## 3. Tool Reference ### Quick protocol All MCP tools are composite — call with `{ action: "..." }`. Call `get_tool_guide { domain }` first if unsure of params. **Buyer flow:** `platform({ action: "search_listings" })` → `branch({ action: "create", parent_id: "" })` (opens at `in_proposal`) → either party `edit`s / `accept`s until `agreed` → seller calls `branch({ action: "finalise" })` (verifies) → `branch({ action: "purchase" })` **Seller flow:** `listing({ action: "create" })` → auto-verified → `account({ action: "dashboard" })` to monitor branches **Contract flow:** `contract({ action: "draft_from_brief", brief })` → `contract({ action: "create" })` → `contract({ action: "propose" })` → counterparty `accept` (or `edit` to counter) → `finalise` to execute **Key hints:** - Every tool response includes `next_steps` and `hints` — always surface `next_steps` to the user - `action_needed: true` in a branch or contract response means the current user must act - Branch branch code format: `PAYCODE/Bn` e.g. `PAY-9421/B2` — use this as `listing_id` for the lifecycle verbs - Either party can `edit` a live branch at any time; only the seller's `finalise` sets `verified: true` (same `verified`/`status` fields a listing uses — no separate branch status) - Every write echoes `revision` + a `can` list — read `can` for exactly what you may do now. Pass `expected_revision` to avoid writing over terms you never saw - `listing({ action: "get", listing_id: "PAY-9421/B2" })` fetches a branch and returns a full `protocol` + `suggested_action` guidance layer --- ### Response envelope Every MCP tool response uses this envelope: Success: ```json { "success": true, "listings": [...] } ``` Error: ```json { "isError": true, "error": "...", "message": "..." } ``` Some tools return a soft error (no `isError`, but an `"error"` key in the result) — always check for both patterns. ### Common object shapes **Listing** (abbreviated): ```json { "_id": "64f...", "paycode": "ABC123", "title": "...", "description": "...", "price": 99.00, "status": "pending", "verified": false, "category": "...", "condition": "...", "type": "...", "tags": [], "seller": { "handle": "...", "_id": "..." } } ``` **Contract** (abbreviated): ```json { "id": "64f...", "contractcode": "CTRKAVRD", "title": "...", "revision": 1, "phase": "draft", "parties": [ { "role": "creator", "handle": "@...", "accepted_at_revision": null, "required": true, "can_finalise": true }, { "role": "counterparty", "handle": "@...", "accepted_at_revision": null, "required": true, "can_finalise": true } ], "sections": [{ "name": "...", "text": "..." }], "url": "https://go-dash.app/contract/CTRKAVRD", "created_at": "..." } ``` The response also carries top-level `phase`, `revision`, `can` (actions available to you now), `action_needed`, `waiting_on`, and `next_steps`. **Authority — without a grant you may read, but not act.** Acting on an agreement, or spending money, requires the account holder to have granted your credential that authority. One rule, no exceptions by phase or verb: - **Needs the grant** — `create`, `propose`, `edit`, `accept`, `finalise`/`verify`, `close`, and any real `purchase`. - **Always free** — search, browse, get, timeline, revisions, comment, and `purchase` with `dry_run: true`. You do not need to memorise that. **`can` already reflects it** — an ungranted agent simply never sees agreement actions in `can`, so following `can` keeps you right. `dry_run` is never gated, so you can always tell your human what something would cost before asking for anything. Both agent channels are covered: an API key, and an OAuth connection such as Claude or ChatGPT. Acting without the grant returns: ```json { "error_code": "agent_not_authorised", "retryable": false, "detail": "... The key \"Claude agent\" has not been granted that authority." } ``` This is NOT retryable — unlike `revision_conflict`, no amount of retrying resolves it, because a person has to act. The `hint` names the remedy for your credential: - **OAuth connection** (Claude, ChatGPT, any connector): the user reconnects Dash and ticks "Allow this app to accept and execute agreements on my behalf" on the sign-in screen. The grant lives on the connection, so there is nothing to toggle in their profile. - **API key**: the user enables "Allow agents using this key to accept and execute agreements on my behalf" at go-dash.app under Profile > API Keys. **A separate refusal: `quota_exceeded`.** Executing a contract counts against the CREATOR's monthly allowance — 5 free per calendar month, then charged. When the creator has used their allowance and has no payment method, or a payment has failed, `finalise` returns: ```json { "error_code": "quota_exceeded", "reason": "payment_method_required", "executions_used": 5, "free_allowance": 5, "next_execution_cents": 60, "period_resets": "...", "retryable": false } ``` `isError` is **false** — like `revision_conflict`, this is a state to relay and act on, not a crash. But unlike it, retrying never helps: a person must add a payment method or wait for the month to reset. Tell your human the number and the remedy in the `hint`. Everything else still works at any volume — drafting, proposing, negotiating, accepting, comments. Only execution is gated. If you are the COUNTERPARTY rather than the creator, you get the same error code without the figures: another account's usage and pricing are not yours to see. Contact the other party; the agreed terms are unaffected. **A separate refusal: `payment_setup_required`.** A buyer-initiated agreement asks a SELLER to do work and be paid for it, so Dash checks the buyer can actually pay before creating one. This applies to: - `listing { action: "create", requested_from_seller_handle: ... }` — a buyer request - `branch { action: "create" }` — a branch is always buyer-initiated A seller listing something they own is NOT affected: they are being paid, not paying. ```json { "error_code": "payment_setup_required", "reason": "no_payment_method", "has_payment_method": false, "has_billing_address": true, "retryable": false, "recovery_tool": "account { action: \"payment_setup\" }" } ``` `reason` is `no_payment_method` or `no_billing_address`. Both a saved card AND a billing address are required — the Stripe-hosted setup flow collects them together, and a buyer with one but not the other still fails at purchase, which is the wasted seller work this prevents. **Not retryable.** Call `account { action: "payment_setup" }` to get a Stripe-hosted URL that collects both, give it to your human, and wait for them to complete it. Retrying before then fails identically. Check first with `account { action: "payment_readiness" }` if you want to avoid the refusal rather than handle it. **A separate refusal: `api_key_read_only`.** API keys also carry scopes. A key created with read access only is refused on any write, whether or not authority was granted — the scopes are fixed at creation and cannot be widened. The remedy is a new key, not a checkbox. Keys created in the web app always have write access; only the REST API can mint a narrower one. Also NOT retryable. Wait for them to confirm before retrying. Reading, drafting, editing, proposing and closing are unaffected; only the two binding actions are gated. A Cognito session is never gated — that IS the account holder. When the authority is used, the date it was granted is recorded on the party and printed in the executed record, so "accepted by an agent" always carries the delegation behind it. **`disclosure` — surface this BEFORE accepting or finalising.** Whenever `can` includes `accept` or `finalise`, the response carries a `disclosure` string stating in plain words what that action commits the user to, plus a `disclosure_version`. Show it to the user and get their agreement before calling `accept` or `finalise` on their behalf — do not accept first and explain after. It is a separate field from `next_steps` precisely so you can present it as the consequence rather than as a suggestion. For a contract it says the agreement becomes legally binding once every required party has accepted the same version; for a listing it says verifying publishes and freezes the terms, which does NOT bind a buyer. `disclosure` is null when the viewer has no binding action available. The version shown is recorded against that party when they act, so the executed record always reproduces the exact wording they saw. **Executed-contract record (PDF).** When a contract executes, Dash generates a self-contained PDF: the terms as executed, every party with how they authenticated and when, the disclosure wording each of them was shown, and a SHA-256 digest of the exact terms so any later alteration is detectable. It is emailed to every party as an attachment — including a guest signer, who has no account and for whom that email is their only copy — and stored for later download. - `executed_pdf.available` on a finalised contract says whether the record exists. - Download: `GET /api/contracts/{contractcode}/pdf` (REST, authenticated, parties only). It redirects to a short-lived presigned URL; there is no MCP tool for it, so offer the user that link rather than trying to fetch bytes through a tool call. - Generation never blocks execution. If storage is briefly unavailable the contract still executes and the record is created on first download instead. **Acceptance audit trail (listings, branches and contracts alike).** Once a party has acted, their party object also carries evidence of HOW they acted. These are written wherever the acceptance pointer moves — `propose`, `edit`, `accept` and `finalise` all move it — so they always describe the action that set the current `accepted_at_revision`: | Field | Meaning | |-------|---------| | `accepted_via` | `web_session` (a person in a browser), `api_key` (an agent using a key), `oauth` (a connected app such as Claude or ChatGPT acting for them), or `guest_link` | | `accepted_client` | the user-agent, or the agent/MCP client name | | `accepted_ip` | the caller's IP | | `accepted_at` | when that action happened | The agreement itself carries `finalised_via`, `finalised_client` and `finalised_ip` for whoever finalised (verified a listing/branch, executed a contract). `accepted_via` is the field that distinguishes a person acting for themselves from an agent acting for them. Do NOT read `accepted_ip` as the person's location when `accepted_via` is `api_key` — it is the agent's infrastructure, not the human's. When surfacing who did what, say so plainly: "@handle accepted via an agent (claude-code)" rather than implying they clicked it themselves. NOTE: Section fields are `name`/`text`, not `title`/`content`. Short identifier is `contractcode` (prefixed `CTR`, distinct from listing paycodes). Use `contract.contractcode` in URLs, not `contract.id`. --- ### Tool structure Tools are composite — each domain tool accepts an `action` param. The per-action reference below shows what each action does, its params, and its response shape. When using a composite tool, pass `action` plus the relevant fields. Example: `listing({ action: "create", title: "...", price: 99 })`. For the full action list with required params and examples for any domain, call `get_tool_guide({ domain: "listing"|"branch"|"contract"|"collection"|"account"|"seller"|"match"|"platform"|"comment" })`. --- ### PUBLIC ACTIONS — no auth required (via `platform` tool or `listing` read actions) #### search_listings Search active listings by keyword, category, price range, or seller. For buyer-facing queries (searching to purchase), pass `verified: true` — unverified listings cannot be purchased and will appear above relevant verified results if not filtered. Inputs: - `query` (string, optional) — keyword search - `category` (string, optional) - `min_price` / `max_price` (number, optional) - `verified` (boolean, optional) — when true, return only verified (purchasable) listings. Recommended for buyer-facing searches. - `seller_handle` (string, optional) - `smart` (boolean, optional, default true) — Bedrock query enrichment; catches vocabulary mismatches between buyer queries and seller tags - `limit` (number, optional, default 20) - `offset` (number, optional) Response: `{ "count": N, "listings": [...] }` #### get_listing Get a single Listing by ID, paycode, or composite branch handle. Inputs: - `id` (string, required) — MongoDB ID, paycode (with or without #), or branch code of the form `PAY-9421/B2` (or `PAY-9421/2`) to fetch a specific branch under that parent. Response: `{ "listing": { ... } }`. For branches the response also includes `parent_paycode`, `parent_revision`, `parent_status`, and `parent_sync_status` (`in_sync` | `behind`) so you can detect parent revision drift. #### search_seller_catalogue Search a specific seller's active Listings. Use this to browse a seller's offerings before opening a Branch — it returns paycodes, prices, variables, and variable_sets so an agent can confirm what's available and select the right variable_id. Inputs: - `handle` (string, required) — seller handle, with or without leading @. - `query` (string, optional) — free-text search across name / title / description / paycode. - `category` (string, optional) - `limit` (number, optional, default 20, max 50) - `skip` (number, optional) Visibility: - The seller's `listing_visibility` setting gates access: `private` blocks all non-owner callers; `agents` (default) requires Bearer or X-API-Key; `public` allows unauthenticated callers. - Returns `{ seller: { handle, listing_visibility }, count, items: [...] }`. #### get_categories Get all categories with listing counts. Inputs: none Response: `{ "categories": [{ "name": "...", "count": N }] }` #### get_collection Get a collection by ID or paycode, including its listings. Inputs: - `collection_id` (string, required) — MongoDB ID or paycode Response: `{ "collection": { "_id": "...", "name": "...", "listings": [...] } }` #### get_user_profile Get a public user profile by handle. Inputs: - `handle` (string, required) Response: `{ "user": { "handle": "...", "bio": "...", "listings": [...] } }` #### get_seller_profile Get seller details including active listings and Stripe connection status. Inputs: - `handle` (string, required) Response: `{ "seller": { "handle": "...", "stripe_authenticated": true, "listings": [...] } }` To link a user to a seller's profile in the web app, use `/sellers/{handle}` (e.g. `https://go-dash.app/sellers/flowerflow`). Clicking any seller handle in the app navigates to this page. #### search_sellers Search for sellers by keyword or handle fragment. Inputs: - `query` (string, required) - `limit` (number, optional) Response: `{ "count": N, "sellers": [{ "handle": "...", "bio": "..." }] }` #### get_top_listings Get top/featured listings (most liked, most requested, or recently active). Inputs: - `sort` (string, optional) — "liked" | "requested" | "recent" - `limit` (number, optional) Response: `{ "listings": [...] }` #### browse_collections Browse public collections with optional filtering. Inputs: - `query` (string, optional) - `limit` (number, optional) - `offset` (number, optional) Response: `{ "count": N, "collections": [...] }` #### get_server_status Check server health and feature flags. Inputs: none Response: `{ "status": "ok", "mcp_enabled": true, "site_locked": false }` --- ### LISTING TOOLS (10) — bearer or API key required #### create_branch Open a new branch against a parent Listing. Either party can create a branch: - **Buyer creates**: expresses interest and intent (qty, price, notes). Seller is derived from the parent listing. - **Seller creates**: sends a custom offer to a specific buyer using `buyer_handle`. The seller's identity is the same as the parent listing's seller. Tagged buyer receives a notification. Multiple branches can coexist under one parent; each gets its own `branch_index` and branch code. Use `search_seller_catalogue` (or `search_listings`) first to find the parent. Then call `create_branch`. Inputs: - `parent_id` (string, required) — parent Listing's paycode (e.g. `PAY-9421`) or MongoDB ObjectId. Must be a Listing — branching off a Branch is rejected. - `intent` (object, optional, all fields optional): - `qty` (number) — quantity sought - `target_price` (number) — per-unit price the buyer is targeting (falls back to parent.price) - `selected_options` (object) — buyer's variable selections, e.g. `{ "Size": "M", "Colour": "Navy" }`. Stored as `selection.variables` on the Branch. If a value isn't in the parent's variable list, a `clarify_variable` event is emitted. - `matched_variant_sku` (string) — the `variable_id` from the parent's `variable_sets` that matches the selection (if known). Stored as `selection.variable_id` on the Branch. - `condition` (string) - `lead_time_days` (number) - `notes` (string) - `buyer_handle` (string, optional) — **seller-created branches only**. Tag a buyer by their Dash handle (e.g. `"tjcarlton6"` or `"@tjcarlton6"`). Tagged buyer receives a notification: `"@{seller_handle} tagged you on {branch_code}"`. If the handle doesn't match any user, returns an error. Response: `{ "success": true, "branch_id": "...", "parent_paycode": "PAY-9421", "branch_index": 2, "branch_code": "PAY-9421/B2", "status": "active", "verified": false, "parent_revision_at_link": 3, "url": "https://go-dash.app/listing/PAY-9421/B2" }` Subsequent operations on this branch use the branch code: `get_listing("PAY-9421/B2")`, `get_listing_timeline` with the branch code, etc. #### get_listing_branches List all branches opened against a parent listing. Requires auth — branches carry buyer identity, so they are never exposed to an unauthenticated caller. Inputs: - `listing_id` (string, required) — parent listing ID or paycode - `status` (string, optional) — `"open"` (unverified) or `"verified"`. Omit to return all. Response: `{ "parent_paycode": "PAY-9421", "parent_id": "...", "count": N, "action_needed_count": N, "branches": [{ "_id": "...", "id": "...", "branch_index": 1, "branch_code": "PAY-9421/B1", "url": "https://go-dash.app/listing/PAY-9421/B1", "phase": "in_proposal", "revision": 1, "verified": false, "status": "active", "price": 100, "buyer": { ... }, "created_at": "...", "action_needed": true }], "hints": [...] }` #### get_my_branches List branches you are party to — as buyer (branches you opened) or seller (branches opened against your listings). Bearer required. Inputs: - `role` (string, optional) — `buyer` or `seller`. Omit to return both sides. - `status` (string, optional) — `"open"` (unverified) or `"verified"`. Response: `{ "mode": "...", "count": N, "has_more": false, "next_cursor": null, "action_needed_count": N, "stale_count": N, "branches": [{ "_id": "...", "id": "...", "branch_code": "PAY-9421/B1", "url": "...", "phase": "in_proposal", "revision": 1, "verified": false, "status": "active", "title": "...", "price": 100, "parent_paycode": "PAY-9421", "my_role": "buyer"|"seller", "action_needed": true, "stale": false, "days_open": 2, "created_at": "..." }], "suggested_action": "...", "next_steps": [...], "hints": [...] }` Use `branch({ action: "list_mine", role: "seller" })` as a seller to see your branch inbox — every branch open against your listings. #### Branch lifecycle workflow Branches ARE the proposal context — no `request_id` is needed. Pass the branch's ObjectId or branch code (e.g. `PAY-9421/B2`) as `listing_id` to the lifecycle verbs. A branch is a Listing with `parent.id` set — it uses the SAME `verified`/`status` fields as any listing (no separate branch-only status machine). Opening a branch IS the proposal, so it starts at `phase: "in_proposal"`, not `draft`. Either party may `edit` and `accept`; once both are caught up at the same `revision` the phase derives to `agreed`; the seller then `finalise`s (verifies), which sets `verified: true` and makes it purchasable — directly and repeatedly, exactly like a listing. There is no terminal "purchased" state — purchases accumulate in `stats.purchases` while the branch stays verified. **Full end-to-end Listing → Branch → Propose changes → Purchase flow:** ``` # Step 1: Seller creates a Listing with variables/variable_sets create_listing({ "title": "Merino Wool Jumper", "description": "100% Australian merino wool, mid-weight knit.", "price": 145, "type": "product", "category": "Clothing", "condition": "New", "variables": [ { "name": "Size", "values": ["XS","S","M","L","XL"] }, { "name": "Colour", "values": ["Oatmeal","Navy","Charcoal"] } ], "variable_sets": [ { "variable_id": "MWJ-S-NAV", "price": 145, "stock": 8, "attributes": { "Size": "S", "Colour": "Navy" } }, { "variable_id": "MWJ-M-NAV", "price": 145, "stock": 12, "attributes": { "Size": "M", "Colour": "Navy" } }, { "variable_id": "MWJ-L-OAT", "price": 145, "stock": 10, "attributes": { "Size": "L", "Colour": "Oatmeal" } } ], "shipping_policy": "Dispatched within 3 business days.", "returns_policy": "Free returns within 30 days." }) # -> result.listing.paycode = "#86UVYG", auto-verified (seller-created) # Note: if you need to add variables after creation, use update_listing before calling verify_listing # Step 2: Buyer browses and reads the listing to see variables get_listing({ "id": "86UVYG" }) # -> listing.variables: [{ name: "Size", values: [...] }, { name: "Colour", values: [...] }] # -> listing.variable_sets: [{ variable_id: "...", price: 145, attributes: {...} }, ...] # Step 3: Buyer creates a branch — confirms variable selection and target price create_branch({ "parent_id": "86UVYG", "intent": { "qty": 1, "selected_options": { "Size": "S", "Colour": "Navy" }, "matched_variant_sku": "MWJ-S-NAV", "target_price": 145 } }) # -> branch_code: "86UVYG/B1", status: "active", verified: false, url: "https://go-dash.app/listing/86UVYG/B1" # -> branch.selection.variables = { Size: "S", Colour: "Navy" }, selection.variable_id = "MWJ-S-NAV" # Step 4: Seller reads the branch, then the buyer edits the terms. # Either party may edit at any time — it is not turn-gated. Editing also # implicitly accepts for the editor. branch({ "action": "get", "listing_id": "86UVYG/B1" }) # -> phase: "in_proposal", revision: 1, can: ["edit","accept","close"], # selection.variables: { Size: "S", Colour: "Navy" }, parent_sync_status: "in_sync" branch({ "action": "edit", "listing_id": "86UVYG/B1", "changes": { "shipping_policy": "Please confirm express dispatch within 24hrs." }, "expected_revision": 1 }) # -> { revision: 2, phase: "in_proposal", waiting_on: "@seller", # can: ["edit","close"], next_steps: [...] } # The buyer has now implicitly accepted at revision 2; the seller is outstanding. # Step 5: Seller accepts, then verifies. Accepting does NOT bump the revision. # ONLY the seller's finalise verifies a branch — buyer acceptance alone never does. branch({ "action": "accept", "listing_id": "86UVYG/B1", "expected_revision": 2 }) # -> { revision: 2, phase: "agreed", purchasable: false, can: ["finalise","edit","close"] } # Note: agreed is NOT purchasable. Verify is a separate gate. branch({ "action": "finalise", "listing_id": "86UVYG/B1", "expected_revision": 2 }) # -> { phase: "finalised", verified: true, status: "active", purchasable: true } # Step 6: Buyer purchases the verified branch branch({ "action": "purchase", "listing_id": "86UVYG/B1" }) # -> purchase recorded; stats.purchases increments. The branch stays verified — # it can be purchased again by the same or another buyer (no terminal status). ``` **Counter-proposal example (buyer pushes back) — a counter is just an `edit`:** ``` # Seller edited at revision 2, so the buyer's acceptance went stale automatically # and waiting_on is now the buyer. branch({ "action": "get", "listing_id": "86UVYG/B1" }) # -> revision: 3, phase: "in_proposal", can: ["edit","accept","close"] # Buyer counters by editing rather than accepting: branch({ "action": "edit", "listing_id": "86UVYG/B1", "changes": { "price": 140, "shipping_policy": "Express included." }, "expected_revision": 3 }) # -> { revision: 4, waiting_on: "@seller" } # This bumped the revision, so the SELLER's acceptance is now stale in turn. # Seller accepts the counter, then verifies: branch({ "action": "accept", "listing_id": "86UVYG/B1", "expected_revision": 4 }) # -> { phase: "agreed" } branch({ "action": "finalise", "listing_id": "86UVYG/B1", "expected_revision": 4 }) # -> { phase: "finalised", verified: true, purchasable: true } ``` **Optimistic locking example (the terms moved under you):** ``` # You read revision 2, but the other party edited to 3 before your write landed. branch({ "action": "edit", "listing_id": "86UVYG/B1", "price": 130, "expected_revision": 2 }) # -> { error: "revision_conflict", expected_revision: 2, actual_revision: 3, # current: { phase: "in_proposal", revision: 3, price: 145, can: [...] } } # # isError is FALSE — this is retryable, not a failure. `current` already holds the # fresh state, so retry straight against it. Do NOT re-fetch blindly. branch({ "action": "edit", "listing_id": "86UVYG/B1", "price": 130, "expected_revision": 3 }) # -> { revision: 4 } ``` **Key rules for the branch lifecycle:** - Never pass `request_id` for branches — branches are self-contained proposal contexts - Use the branch code (`PAY-9421/B2`) as `listing_id` on the lifecycle verbs - `branch { action: "create" }` takes `parent_id` — NOT `listing_id` (that is the lifecycle param) - A branch opens at `phase: "in_proposal"`, never `draft` — opening it IS the proposal - Either party may `edit` at any time; there is no turn lock and no "self-edit rule" - Read `can` and `waiting_on` to know what you may do and whose turn it is — never infer it - ONLY the seller's `finalise` sets `verified: true`; buyer `accept` alone never verifies - `agreed` is NOT purchasable — `finalised` is - A branch uses the same `verified`/`status` fields as a listing (there is no separate branch-only status field). Lifecycle: `in_proposal` → `agreed` (both caught up) → seller `finalise`s → `verified: true`, `status: "active"` → purchasable directly and repeatedly (purchases accumulate in `stats.purchases`; there is no terminal "purchased" state) - `parent_sync_status` on a branch: `"in_sync"` or `"behind"` — `"behind"` means the parent listing changed after this branch was opened #### get_my_listings Get all listings owned by the authenticated user. Inputs: - `status` (string, optional) — filter by "pending" | "active" | "inactive" - `limit` (number, optional) Response: `{ "count": N, "listings": [...] }` #### create_listing Create a new listing. Seller-created listings are auto-verified immediately. Inputs: - `title` (string, required) - `description` (string, required) - `price` (number, required) — use 0 if the price is not yet determined - `price_type` (string, optional) — "one_off" | "recurring" (default "one_off") - `billing_period` (string, optional) — "weekly" | "monthly" | "quarterly" | "annually". Only relevant when `price_type` is "recurring". - `category` (string, optional) — any string; defaults to "Other" - `type` (string, optional) — "product" | "service" (default "product") - `tags` (array of strings, optional) - `requested_from_seller_handle` (string, optional) — makes this a **buyer request** instead of a listing you sell yourself: pass the handle of the seller you want it from (e.g. `"oakandstitch"`), and the listing is created with that seller as the seller party — they alone can finalise (verify) it, and it isn't purchasable or visible in search until they do. You are seeded as a required buyer party and the seller is notified. Must be a seller other than yourself. Omit to list something you sell directly (defaults to your own handle). - `returns_policy` (string, optional) — max 300 chars - `shipping_policy` (string, optional) — max 300 chars - `variables` (array, optional) — variable groups. Each entry: `{ "name": "Size", "input_type": "options"|"text", "values": ["S","M","L"] }` (options type) or `{ "name": "Material", "input_type": "text", "detail": "100% organic cotton" }` (detail type). Use for products or services that have configurable options or descriptive attributes. - `variable_sets` (array, optional) — per-variable_id price/stock entries. Each entry: `{ "variable_id": "ITEM-M-BLK", "price": 89, "stock": 10, "attributes": { "Size": "M", "Colour": "Black" } }`. Attributes must match the variable names defined in `variables`. - `images` (array of strings, optional) — image URLs. When importing from an external page, pass a single-element array with the main/hero image URL (e.g. `["https://example.com/product.jpg"]`). Do not pass every image variant — one image per listing. Response: `{ "success": true, "message": "Listing created: ...", "listing": { "id": "...", "title": "...", "paycode": "#ABC123", "price": 350, "url": "https://go-dash.app/listing/ABC123" } }` IMPORTANT: - The new listing is nested under `result.listing`. Access paycode as `result.listing.paycode`. - Seller-created listings are auto-verified. Call `verify_listing` explicitly only for listings you want to verify after creation (e.g. listings created without `variables`/`variable_sets` that you then update before publishing). - `variables` and `variable_sets` can also be added after creation via `update_listing` before verifying. - A buyer CAN originate a listing under a different seller's handle — pass `requested_from_seller_handle`. This is a buyer request (starts `in_proposal`, not auto-verified), not a catalogue listing you sell yourself. The response's `listing.is_buyer_request` and `listing.seller_handle` reflect who the seller party actually is, which may differ from the caller. #### create_listings_bulk Create up to 10 listings in a single call. Inputs: - `listings` (array, required) — array of listing objects. Each item supports the same fields as `create_listing`: `title`, `description`, `price`, `price_type`, `billing_period`, `category`, `type`, `tags`, `images`, `variables`, `variable_sets`, `returns_policy`, `shipping_policy`. Max 10 per call. For stores with more than 10 items, loop with multiple calls. Response: `{ "success": true, "listings": [...], "total_created": N, "total_errors": N, "errors": [...] }` Each listing in the response array includes `_id`, `paycode`, `title`, `price`, and `url`. Check the `errors` array after each call — partial failures are possible. Retry failed items individually with `create_listing` if needed. #### Workflow: Importing listings from an external store (Shopify, Squarespace, etc.) When `LISTING_EXTRACTOR_ENABLED=true`, use `extract_listing_from_reference` to do the heavy lifting — it handles fetching, parsing, and schema mapping automatically. **Preferred path (extractor enabled):** 1. Call `extract_listing_from_reference` with `reference_type: "page_url"` and the store URL. The server fetches the page and Bedrock extracts structured listing drafts automatically. 2. Show the drafts to the user. For any listing where `confidence` is `"low"` or `unclear_fields` is non-empty, ask the user to confirm those specific fields. 3. Once confirmed, batch into groups of ≤10 and call `create_listings_bulk`. 4. Check the `errors` array in each response and report the final tally. **Fallback path (extractor not enabled / manual mapping):** 1. **Fetch the page** using your own web browsing capability — Dash has no scraping. 2. **Call `get_categories`** to see what categories already exist on Dash (sorted by usage count). 3. **Map each product:** - `title` — product name - `description` — buyer-friendly description; summarise if very long. - `price` — numeric price in AUD. Use 0 if unclear. - `category` — closest existing Dash category; put subcategories in `tags`. - `condition` — "New" for retail products unless stated otherwise. - `type` — "product" for physical goods, "service" for services. - `tags` — subcategories, materials, styles, brands. - `images` — single hero image URL as `["https://..."]`. - `variables` / `variable_sets` — only if the product has clear named variants. 4. Batch into groups of ≤10 and call `create_listings_bulk`. Loop for >10 items. 5. Check `errors` in each response; retry failures individually with `create_listing`. 6. Confirm with the user before creating if the count is large (>5) or any mapping is uncertain. #### extract_listing_from_reference Extract one or more listing drafts from any product reference. Gated by `LISTING_EXTRACTOR_ENABLED=true` (also requires `BEDROCK_ENABLED=true`). Inputs: - `reference_type` (string, required) — `"image_url"` | `"image_base64"` | `"page_url"` | `"text"` - `reference_value` (string, required) — the reference itself: - `image_url` — public URL to a product image; fetched server-side and sent to Bedrock vision - `image_base64` — base64-encoded image (with or without `data:` prefix) - `page_url` — webpage URL (Shopify, Squarespace, etc.); server fetches and strips HTML - `text` — free-form product description or paste of product details Response: ```json { "success": true, "count": 3, "listings": [ { "title": "Merino Wool Crew Neck", "description": "100% Australian merino, mid-weight knit.", "price": 145, "category": "Clothing", "condition": "New", "type": "product", "tags": ["merino", "wool", "knitwear"], "images": ["https://example.com/product-hero.jpg"], "variables": [{ "name": "Size", "values": ["S","M","L","XL"] }], "variable_sets": [], "confidence": "high", "unclear_fields": [] } ], "warnings": ["Price could not be determined for 'Linen Tote' — confirm before creating."] } ``` Fields `confidence` and `unclear_fields` are agent-facing metadata — present them to the user before calling `create_listings_bulk`. Do not strip them before review. SSRF protection: only `https://` URLs are accepted; private/loopback IPs are blocked. Image size limit: 4 MB. Page content limit: 500 KB raw HTML (stripped to ~30 000 chars). --- ### AGENT-ASSIST TOOLS (2) — Bedrock-backed step reducers Gated by `BEDROCK_ENABLED=true`. Both force structured output via Bedrock's toolConfig and return a `confidence` field plus next-step guidance. Use them to skip manual structuring steps when an agent has been handed a short brief. MCP mapping: `infer_branch_intent` -> `branch { action: "infer_intent" }`, `draft_contract_from_brief` -> `contract { action: "draft_from_brief" }`. (`extract_listing_from_reference` -> `listing { action: "extract" }` and `extract_listings_batch` -> `listing { action: "extract_batch" }` are documented under the listing tools.) #### infer_branch_intent Parse a natural-language buyer brief into the structured fields a Dash branch request needs. Inputs: - `brief` (string, required) — e.g. `"I need 50 navy hoodies, size M, by Friday, under $20 each"` Response: ```json { "success": true, "qty": 50, "target_price": 20, "deadline": "2026-05-29", "notes": "size M, navy", "confidence": "high", "unclear_fields": [], "next_steps": "Pass these fields straight into create_branch as the request payload." } ``` When `unclear_fields` is non-empty, confirm them with the user before calling `create_branch`. The deadline is resolved against the current server date; "Friday" returns the next Friday. #### draft_contract_from_brief Draft a structured contract from a natural-language brief describing an agreement between two parties. Always includes the core sections (Scope of Work, Deliverables, Payment Terms, Timeline, Termination) and adds others (Confidentiality, IP Ownership, Liability) when the brief warrants. Where the brief is silent, a clear `"To be agreed: ..."` placeholder is inserted and that section is added to `unclear_fields`. Inputs: - `brief` (string, required) — natural-language description of the agreement - `counterparty_handle` (string, optional) — with or without leading `@` Response: ```json { "success": true, "title": "Brand identity consulting engagement", "sections": [ { "name": "Scope of Work", "text": "@studio will deliver brand identity for @acme..." }, { "name": "Deliverables", "text": "Logo, palette, typography spec, brand guidelines PDF." }, { "name": "Payment Terms", "text": "$8,000 AUD on accept; $8,000 on final delivery." }, { "name": "Timeline", "text": "6 weeks from contract execution." }, { "name": "Termination", "text": "Either party may terminate with 14 days notice." } ], "confidence": "medium", "unclear_fields": ["Confidentiality"], "next_steps": "Resolve unclear sections with the user, then pass title + sections to create_contract." } ``` #### update_listing Update fields on an existing listing. Only works on unverified listings (or your own listings where you are the seller). Inputs: - `listing_id` (string, required) — MongoDB ID or paycode - `title`, `description`, `price`, `price_type`, `billing_period`, `category`, `type`, `tags` (all optional) - `variables` (array, optional) — replaces existing variable groups. Same schema as `create_listing`. - `variable_sets` (array, optional) — replaces existing variable_sets list. Same schema as `create_listing`. - `returns_policy` (string, optional) — max 300 chars - `shipping_policy` (string, optional) — max 300 chars Response: `{ "success": true, "listing": { ... } }` #### verify_listing Verified seller manually verifies a listing (sets verified: true, status: "active"). Inputs: - `listing_id` (string, required) Restricted to verified sellers only (Stripe-connected, stripe_authenticated=true). Returns 403 if the seller does not have a connected Stripe account. Once verified, no more proposals can be made. Verification also happens automatically when a proposal is approved or a listing is approved via the approve endpoint (if the seller is verified). Response: `{ "success": true, "listing": { "verified": true, "status": "active", ... } }` #### toggle_listing_status Toggle a verified listing between "active" and "inactive". Inputs: - `listing_id` (string, required) Response: `{ "success": true, "listing": { "status": "active" | "inactive", ... } }` #### get_listing_timeline Get the full event history for a listing. Inputs: - `listing_id` (string, required) For a listing: `{ "listing_id": "...", "paycode": "#ABC", "count": N, "events": [...] }` For a branch: `{ "branch_id": "...", "branch_code": "PAY/B1", "count": N, "events": [...] }` Each event: `{ "type": "...", "actor": "display name or handle", "metadata": { ... }, "created_at": "..." }` Timeline event types: `created`, `branch_created`, `request`, `change_proposed`, `change_approved`, `change_rejected`, `approved`, `verified`, `like`, `purchase`. Events are sorted newest-first. Use `get_listing_timeline` with the branch code (e.g. `"5A5T4R/B1"`) to get a branch's timeline. Use this as a fallback to retrieve `request_id` from request events if it was not saved from the `request_listing` response. #### get_listing_purchases Get purchase records for a listing (seller view). Inputs: - `listing_id` (string, required) Response: `{ "purchases": [...] }` #### listing { action: "requests" } Your inbound request inbox as a seller: listings CREATED BY BUYERS and addressed to you, awaiting your verification. This is not a list of `Request` records and takes no `listing_id`. Inputs: none (scoped to the authenticated seller). Response: ```json { "requests": [ { "listing": { "_id": "...", "title": "...", "paycode": "#ABC123", "price": 100, "verified": false }, "buyer": { "name": "...", "handle": "..." }, "status": "pending_verification", "created_at": "..." } ], "total": 1, "hints": [ "... call listing { action: \"verify\", listing_id: \"\" } to accept and fulfil ..." ] } ``` `status` here is `"verified"` | `"pending_verification"` — a convenience label derived from whether the listing is finalised. To act on one of these, call `listing { action: "verify", listing_id: "" }` (verify == finalise). --- ### COLLECTION TOOLS (6) — bearer or API key required #### get_my_collections Get all collections owned by the authenticated user. Inputs: - `limit` (number, optional) Response: `{ "count": N, "collections": [...] }` #### create_collection Create a new collection. Inputs: - `name` (string, required) - `description` (string, optional) - `tags` (array of strings, optional) Response: `{ "success": true, "collection": { "_id": "...", "paycode": "...", "name": "...", ... } }` IMPORTANT: Collection is nested under `"collection"` key. Access `_id` as `result.collection._id`. #### update_collection Update a collection's name, description, or tags. Inputs: - `collection_id` (string, required) — MongoDB ID - `name`, `description`, `tags` (all optional) Response: `{ "success": true, "collection": { ... } }` #### add_to_collection Add one or more listings to a collection. Max 20 listings per collection. Inputs: - `collection_id` (string, required) — MongoDB ID - `paycode` (string) — single listing paycode to add - `paycodes` (array of strings) — multiple listing paycodes to add (batch). Pass either `paycode` or `paycodes`, not both. Response: `{ "success": true, "added": N, "already_in_collection": N, "not_found": N, "total_in_collection": N }` #### remove_from_collection Remove a listing from a collection. Inputs: - `collection_id` (string, required) — MongoDB ID - `paycode` (string, required) — listing paycode to remove Response: `{ "success": true }` #### delete_collection Permanently delete a collection. Listings are not deleted. Inputs: - `collection_id` (string, required) — MongoDB ID Response: `{ "success": true }` Note: `collection_id` in all collection tools requires the MongoDB ID (24 hex chars), not the paycode. --- ### PROPOSING CHANGES ON A LISTING OR BRANCH — bearer or API key required Listings and branches use the SAME five lifecycle verbs as contracts, on the `listing` and `branch` tools: ``` listing { action: "propose" | "edit" | "accept" | "finalise" | "close", ... } branch { action: "propose" | "edit" | "accept" | "finalise" | "close", ... } ``` To propose different terms — a counter — use `edit`. **Phase is DERIVED, never set directly:** ``` draft -> in_proposal -> agreed -> finalised (or -> closed) ``` - `draft` — creator-only. A solo catalogue listing starts here; `propose` is not offered when there is nobody to propose to, so the seller may `finalise` (verify) straight from draft. - `in_proposal` — either party may `edit`, `accept`, and comment. **Not turn-gated.** A branch starts here: opening a branch IS the proposal. - `agreed` — every required party has accepted the SAME revision. - `finalised` — the seller has verified. Terms are frozen. **The concurrency model (read this before any write):** - `revision` is a counter on every agreement. **Only `edit` bumps it.** `accept`, `propose`, `finalise` and `close` do not. - Acceptance is a POINTER — each party stores `accepted_at_revision`. An `edit` bumps `revision`, which makes everyone else's acceptance stale automatically. Nothing is reset by hand. - `agreed` means: every required party's pointer == the current `revision`. - Editing implicitly accepts for the editor. The last party to edit is never asked to accept again. - A counter-proposal is just an `edit`. **Only the seller finalises a listing/branch** (`finalise_gate: "finaliser"`), and only a finalised listing/branch is `purchasable`. `agreed` alone is NOT purchasable. Contracts differ: they need consensus first and are never purchasable. #### edit — change terms Inputs: - `listing_id` (string, required) — paycode or ObjectId; for a branch, the branch code (`ABC123/B1`). - Flat fields or `changes` (object). Editable: `name`, `title`, `price`, `description`, `tags`, `returns_policy`, `shipping_policy` (branch also: `selection`). `name` and `title` move together. - `expected_revision` (number, optional) — the revision you last saw. See "Optimistic locking" below. Response echoes the new `revision`, derived `phase`, and your `can` list. Cannot be called once finalised — `edit` simply drops out of `can`. #### accept — signal you are happy with the current terms Inputs: `listing_id` (required), `expected_revision` (optional). Does NOT bump the revision. For a branch, buyer acceptance alone does not verify — only the seller's `finalise` sets `verified: true`. #### finalise — the seller verifies (`finalise_verb: "verify"`) Inputs: `listing_id` (required), `expected_revision` (optional). Sets `verified: true` and makes the listing/branch purchasable. Seller only. #### close — end it Inputs: `listing_id` (required), `reason` (`"withdrawn"` | `"rejected"` | `"voided"`). The `get` response lists which reasons are available to you in `close_reasons`. #### Optimistic locking (all kinds) Pass `expected_revision` on `edit`/`accept`/`finalise`/`close`. If the terms moved under you, the call returns: ```json { "error": "revision_conflict", "expected_revision": 1, "actual_revision": 3, "current": { ... } } ``` **This comes back with `isError: false`** — it is a retryable outcome, not a failure. Read `current` (it holds the fresh state) and retry with the new revision. Do not re-fetch blindly. Only forbidden/validation/not-found responses set `isError`. The REST layer expresses the same condition as HTTP 409. Omitting `expected_revision` means "write regardless". --- ### CONTRACT TOOLS — bearer or API key required Contracts use the same lifecycle verbs as listing and branch: `propose`, `edit`, `accept`, `finalise`, `close`. Phase is DERIVED, never set directly: ``` draft -> in_proposal -> agreed -> finalised (or -> closed) ``` - draft — creator-only, not visible to the counterparty. Creator calls `contract({ action: "propose" })` to send it. - in_proposal — both parties may `edit`, `accept`, and comment. - agreed — both parties have accepted the SAME revision. Either party may then `finalise` (execute). - finalised — executed; final and binding, read-only. - closed — ended via `contract({ action: "close", reason })`: `withdrawn` (creator), `rejected` (counterparty), or `voided` (either party). **Revision + acceptance (optimistic concurrency):** - The contract has one `revision` counter. It increments ONLY on `edit`. - `accept` records that you accept the CURRENT revision. An `edit` bumps the revision, which automatically makes the other party's earlier acceptance stale — they must accept again. The contract reaches `agreed` only when both parties have accepted the same revision. - Pass `expected_revision` (the revision you last saw) on `edit`/`accept`/`finalise`/`close`. If the terms changed under you, the call returns `{ error: "revision_conflict", actual_revision, current }` instead of acting on stale terms — re-fetch with `get` and retry. - Every contract response echoes the current `revision` and a `can` list of the actions available to you right now. Read `can` instead of guessing which action is valid. #### create_contract Create a new contract draft. Inputs: - `title` (string, required) - `counterparty_handle` (string, required) — handle of the other party (with or without leading @) - `counterparty_email` (string, optional) — email of the counterparty. When provided and the handle has no matching account: a placeholder is created so the handle is reserved, and a sign-in invite email is sent. The counterparty clicks the link to register and the contract is waiting for them automatically. - `sections` (array, required) — `[{ "name": "...", "text": "..." }]` NOTE: fields are `name` and `text` — NOT `title`/`content`. Using wrong keys silently creates empty section names. - `listing_id` (string, optional) — link to a specific listing **Non-user counterparties are supported.** Supply `counterparty_handle` (their intended handle) plus `counterparty_email`. The handle is reserved so it auto-links when they sign up. **The invite email is sent when you PROPOSE, not when you create.** A draft is private to its creator — a non-creator party has an empty `can` list in that phase — so emailing at creation would send someone a contract they cannot act on. Create, then `propose`, and that is when they are notified. - `counterparty_mode` (string, optional) — `"full"` (default) or `"guest_accept"`. - `full`: the counterparty signs in to Dash (creating an account if needed) and gets the complete flow — they can accept, counter-propose, or comment. - `guest_accept`: the counterparty signs with **no Dash account at all**. They get a link to a standalone signing page, read the terms, type their name, and one click both accepts AND executes the contract. They CANNOT counter-propose — `can_edit` is false on their party, so `edit` never appears in their `can` list. Use this when the agreement is offered as-is and you want the lowest possible friction, which is the common case for a standard client agreement. Only applies when the counterparty is not already a Dash user; an existing user always gets the full flow. No placeholder account is created. Their signing link carries a single-use token that expires after 14 days. If they would rather negotiate, the page offers them a route to create an account and take over their party, at which point the normal flow applies. If `invited_email` appears in the response, an invite was sent to that address. Surface this to the user: "Invite sent to {email} — they can sign in directly from the email." Cannot create a contract with yourself. Response: ```json { "message": "Contract created", "contract": { "id": "64f...", "paycode": "CTRKAVRD", "title": "...", "status": "draft", "creator": { "name": "...", "handle": "..." }, "counterparty": { "name": null, "handle": "..." }, "sections": [{ "name": "...", "text": "..." }], "url": "https://go-dash.app/contract/CTRKAVRD" } } ``` NOTE: Field is `contract.id` (no underscore), not `_id`. Short identifier is `contract.paycode` (e.g. `CTRKAVRD`) — use this for the URL, not the MongoDB id. Response key is `message`, not `success`. #### get_contract Get a single contract by ID or paycode. Inputs: - `id` or `contract_id` (string, required) — MongoDB ID or paycode. Both are accepted as param names. Both `CTR12345` and `#CTR12345` are accepted — the `#` prefix is stripped automatically. Response: `{ "contract": { ... }, "revision", "phase", "viewer_role", "action_needed", "waiting_on", "can", "close_reasons", "finalise_verb", "next_steps" }`. `can` is the list of actions available to you right now — call `get` before acting to see it and the current `revision`. #### get_my_contracts Get all contracts where the authenticated user is creator or counterparty. Inputs: - `phase` (string, optional) — filter by derived phase. Valid values: `draft` | `in_proposal` | `agreed` | `finalised` | `closed` - `role` (string, optional) — `creator` | `counterparty` - `limit` (number, optional) Response: `{ "count": N, "action_needed_count": N, "contracts": [...] }` (or a `by_phase` summary for large inboxes) #### Lifecycle verbs: propose / edit / accept / finalise / close Use the composite tool: `contract({ action: "", contract_id, ... })`. Every response echoes the current `revision` and a `can` list of the actions available to you. - **propose** — `contract({ action: "propose", contract_id })`. draft → in_proposal; sends it to the counterparty. Creator only. - **edit** — `contract({ action: "edit", contract_id, sections?, title?, expected_revision?, summary? })`. Either party, while in_proposal/agreed. Provided sections are merged by name (unmentioned sections are kept). Bumps `revision` and creates a revision snapshot; the other party's acceptance is invalidated and must be re-given. - **accept** — `contract({ action: "accept", contract_id, expected_revision? })`. Records your acceptance of the current revision. When both parties have accepted the same revision the phase becomes `agreed`. - **finalise** — `contract({ action: "finalise", contract_id })`. Executes the contract (final and binding). Requires phase = `agreed`; either party may call it. - **close** — `contract({ action: "close", contract_id, reason })`. `reason` is `withdrawn` (creator, draft/in_proposal), `rejected` (counterparty, in_proposal/agreed), or `voided` (either party). The `get` response lists which reasons are available to you. Optimistic concurrency: pass `expected_revision` (the revision you last saw). If the terms changed under you, the call returns `{ "error": "revision_conflict", "expected_revision", "actual_revision", "current" }` — re-fetch with `get` and retry against the new revision. #### get_contract_revisions Get the full revision history of a contract. Inputs: - `contract_id` (string, required) Response: `{ "count": N, "revisions": [{ "revision_number": N, "sections": [...], "author": { "handle": "...", "name": "..." }, "summary": "...", "created_at": "..." }] }` #### get_contract_timeline Get the event timeline for a contract (status changes, comments, edits). Inputs: - `contract_id` (string, required) Response: `{ "events": [{ "type": "...", "actor": { "handle": "..." }, "created_at": "..." }] }` #### add_contract_comment Add a comment to a contract thread. **Back-compat wrapper** — internally routes through the unified `add_comment` with `parent_type: "contract"`. Prefer `add_comment` for new integrations. The contract timeline event (`contract_commented`) is still emitted so get_contract_timeline keeps working. Inputs: - `contract_id` (string, required) - `comment` (string, required) — NOTE: field is "comment", not "content" Response: `{ "success": true, "comment": { "id": "...", "body": "...", ... } }` --- ### UNIFIED COMMENT TOOLS (3) — listings, branches, contracts One Comment collection covers all three parent types. Comments live in a dedicated MongoDB collection (NOT embedded on the parent doc) so get_listing / get_branch / get_contract responses stay read-cheap. The parent's `stats.comments` is denormalised for fast count lookups. Visibility (enforced in the handlers): - **Listings** — public read (anyone can list comments), bearer to add - **Branches** — private to `branch.buyer` and `branch.seller` only - **Contracts** — private to `contract.creator` and `contract.counterparty` only Soft delete preserves thread integrity — the row stays, body is wiped, response renders as `"[deleted]"`. Only the author can delete their own comment. **Existing comments warning:** comments added via the legacy `add_contract_comment` tool *before* the unified Comment collection existed still live only as `contract_commented` events on the contract timeline. They will NOT appear in `get_comments` output. New comments (via either tool) are dual-written: stored in the Comment collection AND emitted as a timeline event. #### add_comment Add a comment to a listing, branch, or contract. Inputs: - `parent_type` (string, required) — `"listing"` | `"branch"` | `"contract"` - `parent_id` (string, required) — listing paycode, branch branch code (e.g. `"PAY-9421/B2"`), contract paycode, or MongoDB ObjectId - `body` (string, required) — max 2000 characters Response: ```json { "success": true, "comment": { "id": "...", "parent_type": "listing", "parent_id": "...", "body": "Do you ship to AU?", "author": { "_id": "...", "handle": "alexchen", "name": "Alex Chen" }, "created_at": "2026-05-25T03:42:11.000Z" } } ``` The relevant other party is notified: contract counterparty (with role-aware target), branch counterparty, or listing seller. #### get_comments List comments on a parent, newest first. Paginated. Inputs: - `parent_type` (string, required) - `parent_id` (string, required) — same forms as add_comment - `limit` (number, optional) — default 20, max 100 - `offset` (number, optional) — skip this many for pagination Response: ```json { "parent_type": "listing", "parent_id": "...", "total": 7, "offset": 0, "count": 7, "comments": [ { "id": "...", "author": { "_id": "...", "handle": "alexchen", "name": "Alex Chen" }, "body": "Do you ship to AU?", "deleted": false, "edited": false, "created_at": "2026-05-25T03:42:11.000Z" } ] } ``` For branches and contracts, callers without view permission receive a permission error. #### delete_comment Soft-delete a comment you authored. Body wiped, row preserved. Inputs: - `comment_id` (string, required) — comment MongoDB ObjectId Response: `{ "success": true, "deleted_id": "..." }` Only the author may delete. Marked `destructiveHint: true`. --- ### DEFERRED-AUTH DRAFTS (Round 4 part 2) Lets agents create *draft* listings and contracts that don't go fully live until a human (or another agent) explicitly activates them. Useful for: prototyping on someone else's behalf, multi-step workflows that should commit only at the end, and handing a draft to a different user via a one-time token. **Scope:** supported on **listings** and **contracts** in v1. Branches are not deferrable — they require a buyer identity at creation time. **How it works:** 1. Call `create_listing` or `create_contract` with `defer_auth: true`. Bearer is still required — the calling user owns the draft from day one. 2. The entity is created with `auth_status: "pending_auth"`. Listings get `status: "inactive"` and are excluded from `search_listings`. Contracts are invisible to the counterparty. 3. Response includes a `claim` block: ```json "claim": { "token": "1f7c…", // 48 hex chars "expires_at": "2026-06-01T03:42:11.000Z", // 7-day window "claim_url": "https://go-dash.app/claim?type=listing&token=1f7c…", "note": "Listing is pending_auth — hidden from search until claimed via claim_draft(token)." } ``` 4. Pass the token to `claim_draft({ token })` (bearer required) to activate. **Optional `claim_email`:** include on the create call to allow a *different* user (matching this email) to activate the draft via `claim_draft`. **Claim activates only — it does NOT transfer ownership.** The original creator stays as the seller (listings) or creator (contracts). The supported pattern in v1 is: - *Agent acts on behalf of X → X is owner → X claims later when back online to push live.* The unsupported (v2) pattern is: - *Agent drafts for Y → Y signs up → Y inherits ownership.* This needs placeholder users and an ownership-transfer flow that is not yet implemented. **Visibility while pending:** - Listings: excluded from `search_listings`, `get_top_listings`. `get_listing` returns 404 unless the caller is the owner OR passes `claim_token` in args. - Contracts: returned by `get_contract` to the creator only. The counterparty has no visibility until claim. #### claim_draft Activate a pending_auth listing or contract. Inputs: - `token` (string, required) — the `claim_token` from the draft's creation response Response on success: ```json { "success": true, "entity_type": "listing", "entity": { "id": "...", "paycode": "#9427OV", "title": "...", "url": "https://go-dash.app/listing/9427OV" }, "auth_status": "authenticated", "next_steps": "Listing is now active and discoverable in search." } ``` Errors: - `"No pending draft found for that token."` — bad/already-claimed token - `"Draft has already been claimed."` — caller arrived after another activation - `"Claim token has expired. Create a fresh draft."` — past the 7-day window - `"You do not have permission to claim this draft..."` — caller is neither the original owner nor an email match for `claim_email` The token is single-use — after success, `claim_token` and `claim_token_expires_at` are cleared from the entity. --- ### SOCIAL TOOLS (6) — bearer or API key required #### like_listing Toggle a like on a listing (idempotent — call again to unlike). Inputs: - `listing_id` (string, required) Response: `{ "success": true, "liked": true | false }` #### like_collection Toggle a like on a collection (idempotent — call again to unlike). Inputs: - `collection_id` (string, required) Response: `{ "success": true, "liked": true | false }` NOTE: Liked collections appear in the web app Likes tab alongside liked listings, interleaved in a single date-sorted list (newest first). There is no `get_my_liked_collections` MCP tool — to retrieve liked collections programmatically, use `get_my_collections` (own collections) or `browse_collections` (public collections). The like status of a specific collection can be checked via `GET /api/collections/:id/like-status`. #### get_my_liked_listings Get all listings liked by the authenticated user, newest first. NOTE: Returns liked **listings** only — liked collections are not included in this response. The web app Likes tab shows both liked listings and liked collections interleaved in a single date-sorted list with filter chips (All / Listings / Collections), but there is no MCP equivalent for listing liked collections. Inputs: none Response: `{ "count": N, "listings": [...] }` #### request_listing Express buyer interest in a listing — a signal to the seller, not a proposal. Idempotent: calling again on the same listing returns `{ success: true, already_requested: true }`. Inputs: - `listing_id` (string, required) - `message` (string, optional) — message to the seller Response: ```json { "success": true, "request_id": "...", "next_steps": "Request submitted to the seller. They have been notified and may choose to verify the listing (making it purchasable) or reach out via a branch to propose changes...", "hints": ["You can add context for the seller via comment { action: \"add\", parent_type: \"listing\", parent_id: \"\" }."] } ``` `request_id` is informational only. It is NOT needed to change terms — that is done with `listing`/`branch` `edit`, which takes no `request_id`. To propose different terms, open a branch (`branch { action: "create", parent_id }`) and `edit` there. #### cancel_request Cancel a buyer's request on a listing. Inputs: - `listing_id` (string, required) Response: `{ "success": true }` #### Reversal fields on a purchase Every purchase returned by `account { action: "purchases" }` or `account { action: "sales" }` carries these, cached from Stripe: - `refunded_amount` (number | null) — total returned so far. Non-null with `status: "completed"` means a partial refund. - `dispute` (object | null) — present only while a dispute exists: `{ status, reason, amount, evidence_due_by }`. `evidence_due_by` is the deadline the SELLER has to respond; missing it loses the dispute automatically. Dash never writes these itself — they are what Stripe last reported. If they ever disagree with Stripe, Stripe is right. #### request_refund (MCP: `account { action: "request_refund" }`) Ask the seller of a purchase for a refund. This is the ONLY way to request a refund. A buyer can comment on a listing or a branch, but those are conversations about an item and cannot trigger a refund — a refund is about a specific payment, so it is requested from the transaction. The model rejects a refund request on any other parent. In the Dash app the same thing is done from Account > Transactions, by opening the transaction and using **Request refund**. This ASKS. It does not refund. Dash cannot refund, and no Dash endpoint will: under direct charges the money sits in the seller's own Stripe account, and only they can return it. They do so from the payment panel on the transaction in their Dash account. Use it anyway rather than telling a buyer to dispute the charge with their bank. A dispute takes the money back too, but also costs the seller a fee and gives them only a fixed window to respond. Asking first is faster for the buyer and cheaper for the seller. Inputs: - `purchase_id` (string, required) — from `account { action: "purchases" }` - `message` (string, required) — why a refund is wanted Only the buyer of the purchase may call this, and only while its status is `completed`. A purchase that is already `refunded`, `disputed` or `failed` returns an error naming the status. ```json { "requested": true, "purchase_id": "...", "seller_handle": "@oakandstitch", "amount": 79.95, "currency": "aud", "hints": ["The seller has been notified and decides whether to refund..."] } ``` #### transaction_messages (MCP: `account { action: "transaction_messages" }`) Read, or add to, the private message thread on a purchase. Visible only to the buyer and the seller. Inputs: - `purchase_id` (string, required) - `message` (string, optional) — posts a reply when given; omit to read only - `limit` (number, optional) — capped at 100 This is how an agent finds out what happened after `request_refund`: a seller's answer is a message, not a status change. The response also carries `status` and `refunded_amount`, so a refund that has already been issued is visible without a second call. ```json { "purchase_id": "...", "status": "completed", "refunded_amount": null, "count": 2, "messages": [ { "kind": "refund_request", "body": "...", "author": "@buyer", "mine": true, "created_at": "..." }, { "kind": "comment", "body": "...", "author": "@oakandstitch", "mine": false, "created_at": "..." } ] } ``` #### get_my_requests (MCP: `account { action: "requests" }`) Get the listings the authenticated user has requested. Inputs: - `limit` (number, optional) — capped at 100 Response — note the array is `listings`, not `requests`, and each entry is a full formatted listing with two request fields appended: ```json { "count": N, "listings": [ { "id": "...", "paycode": "#ABC123", "title": "...", "price": 100, "phase": "draft", "revision": 1, "verified": false, "status": "active", "request_status": "active", "requested_at": "..." } ] } ``` `request_status` is the Request record's own `active`/`fulfilled`/`cancelled`/`expired` — unrelated to the agreement lifecycle. Read `phase`/`verified` for where the listing stands. --- ### ACCOUNT TOOLS (6) — bearer or API key required #### get_my_profile Get the authenticated user's full profile. Inputs: none Response: `{ "user": { "handle": "...", "email": "...", "bio": "...", "seller": { ... }, ... } }` NOTE: There is no REST route `GET /api/user/me/profile`. Use this MCP tool or read `data.user` from the login response. #### update_my_profile Update the authenticated user's profile fields. Inputs: - `bio` (string, optional) - `display_name` (string, optional) - `avatar_url` (string, optional) Response: `{ "success": true, "user": { ... } }` #### get_my_notifications Get notifications for the authenticated user. Inputs: - `unread_only` (boolean, optional) - `limit` (number, optional) Response: `{ "count": N, "notifications": [{ "_id": "...", "type": "...", "read": false, ... }] }` #### mark_notification_read (MCP: `account { action: "read_notification" }`) Mark one or all notifications as read. Inputs: - `notification_id` (string, required) — MongoDB ID of the notification, or `"all"` to mark all as read Response: `{ "success": true }` #### purchase_listing Purchase a listing using the buyer's saved payment method. Charges the buyer's default card on file server-side (no browser or redirect required) and routes the payment to the seller's Stripe Connect account. The listing must be active and verified. The buyer must have completed one-time setup: a saved payment method and a billing address (both set via `payment_setup_link` or in Account > Details > Billing Details in the Dash app). Call `check_payment_readiness` first to verify the buyer is set up, OR call `purchase_listing` with `dry_run: true` to validate-and-preview in one step (gathers listing, seller, amount and a readiness block; does not charge). If not ready, the response includes `action_required` and `payment_setup_link` can hand the buyer a one-click URL to complete setup. `purchase_listing` is marked `destructiveHint: true` — MCP clients should confirm with the user before calling (the `dry_run` path makes confirmation cleaner: pass the preview to the user, then call again without `dry_run`). Inputs: - `listing_id` (string, required) — listing MongoDB ID or paycode - `dry_run` (boolean, optional) — when true, validates and previews the charge without committing Response on success: ```json { "success": true, "purchase_id": "...", "listing": "Listing name", "paycode": "#PAY-1234", "amount": 99.00, "currency": "aud", "status": "completed", "charged_at": "2026-05-25T03:42:11.000Z", "buyer": { "handle": "buyerhandle", "name": "Buyer Name" }, "seller": { "handle": "sellerhandle", "name": "Seller Co" }, "stripe_receipt_url": "https://...", "next_steps": "Receipt sent to both parties. View the purchase under Account > Transactions, or fetch via account { action: \"purchases\" }." } ``` `status` is one of `pending`, `processing`, `completed`, `failed`, `refunded`, `disputed`, `canceled`. `refunded` means the charge was returned in FULL. A PARTIAL refund leaves the status `completed` and shows only in `refunded_amount`, so never read `status` alone to answer "was I refunded?". A refunded BUYER receives the full amount they paid — Dash's fee was never added to it. The fee comes out of the SELLER's proceeds and is not returned to the seller on a refund, matching Stripe, which does not return its processing fee either. Tell a seller that plainly if they ask what a refund costs them. #### What a seller actually receives Two separate deductions, and only one of them is Dash's: 1. **Dash's fee** — a fixed percentage of the sale, in the listing's currency. Dash knows this exactly and states it before the listing is published. 2. **Stripe's processing fee** — deducted from the SELLER's own Stripe account, at that account's country pricing, and varying by the buyer's card. **Dash cannot state this figure.** It differs per seller country, has domestic and international tiers judged against the SELLER's country, changes over time, and is not exposed in any API Dash can read. So never tell a seller their net payout from Dash's numbers alone. The figure Dash shows before publishing is the amount BEFORE Stripe's fee, and is labelled that way. The exact amount for a completed sale is in the seller's Stripe balance, which Dash surfaces under Account > Transactions > Balance and Payout Schedule. Currency rules live in one place — see "What currency it is in" above. They are not repeated here, so they cannot drift apart. `disputed` means the buyer's bank raised a chargeback. Only the seller can answer it, from the payment panel on the transaction in Dash. Report it; do not try to act on it, and do not advise a buyer to raise one — see `request_refund`. Surface the full confirmation to the user — never treat a `purchase_listing` response as silent success. At minimum show: listing title, paycode, amount, seller handle, charged_at, and the receipt URL when available. Errors: - `"Listing is not available for purchase."` — status is not active - `"Listing must be verified before it can be purchased."` — seller hasn't verified - `"You cannot purchase your own listing."` — self-purchase blocked - `"No billing address on file. Add one in Account > Details > Billing Details in the Dash app, then retry."` - `"No payment method found. Add one in Account > Details > Billing Details in the Dash app, then retry."` #### purchase_branch Purchase a verified branch (a transaction with agreed terms, opened via `create_branch` and verified through the proposal flow). Same payment plumbing as `purchase_listing` but settles at the agreed branch price rather than the parent listing's posted price. The branch must have `verified: true` (the same field a listing uses). Both parties must have agreed terms via proposals before this call will succeed. A verified branch can be purchased repeatedly, like a listing — there is no terminal "purchased" state. Supports `dry_run: true` — same semantics as `purchase_listing`: previews the charge with all context (branch code, parent paycode, amount, seller, readiness) and does not commit. Inputs: - `listing_id` (string, required) — branch branch code (e.g. `"PAY-9421/B2"`) or branch MongoDB ID - `amount` (number, optional) — override amount in dollars; defaults to the agreed branch price - `dry_run` (boolean, optional) — when true, validates and previews the charge without committing Response on success: ```json { "success": true, "purchase_id": "...", "branch_title": "Custom order — 50 navy hoodies", "branch_code": "PAY-9421/B2", "parent_paycode": "#PAY-9421", "amount": 1100.00, "currency": "aud", "status": "completed", "charged_at": "2026-05-25T03:42:11.000Z", "buyer": { "handle": "buyerhandle", "name": "Buyer Name" }, "seller": { "handle": "sellerhandle", "name": "Seller Co" }, "stripe_receipt_url": "https://...", "next_steps": "Receipt sent to both parties. View the purchase under Account > Transactions, or fetch via account { action: \"purchases\" }." } ``` Errors: - `"Branch is not verified. Current status: ..."` — terms not yet agreed - `"You cannot purchase your own branch."` — self-purchase blocked - Same payment-setup errors as `purchase_listing` #### payment_setup_link Generate a short-lived Stripe-hosted URL the buyer can click to add a card AND a billing address in one flow. Once they complete the hosted setup, the card is attached as their default payment method on the Stripe customer and the collected billing address is saved to their Dash profile automatically (via the `/api/payments/setup-complete` callback). They are then redirected back to `/profile?payment_setup=success`. Use this whenever `check_payment_readiness` or a `dry_run` purchase reports missing payment setup — gives the agent a one-click recovery for the buyer instead of dead-ending with an error. The link is valid for ~24 hours (Stripe Checkout default). Response (no Stripe bypass): ```json { "success": true, "setup_url": "https://checkout.stripe.com/c/pay/cs_test_...", "expires_at": "2026-05-26T03:42:11.000Z", "includes": ["payment_method", "billing_address"], "readiness": { "ready": false, "has_billing_address": false, "has_payment_method": false, "action_required": "Add a billing address and a payment method." }, "next_steps": "Surface setup_url to the buyer. After they complete the Stripe-hosted setup, their card and billing address are saved automatically and they are redirected back to /profile. Retry purchase_listing then." } ``` When `BYPASS_STRIPE` is active the response carries `mode: "bypass"` and no `setup_url` — no charge happens anyway, so no setup is needed. Inputs: none #### check_payment_readiness Check whether the authenticated buyer has the pre-conditions required for autonomous payment: a saved payment method and a billing address. Zero side-effects — safe to call any time. NOTE: this checks **buyer** payment setup only — not whether you can receive funds as a seller. To check seller Stripe receivability (`charges_enabled`, `payouts_enabled`), also call `get_seller_account_status`. A prompt like "can buyers purchase my listings?" should call both tools. Inputs: none Response when ready: ```json { "ready": true, "has_billing_address": true, "has_payment_method": true, "default_payment_method": "pm_..." } ``` Response when not ready: ```json { "ready": false, "has_billing_address": true, "has_payment_method": false, "action_required": "Add a payment method in Account > Details > Billing Details in the Dash app." } ``` #### get_my_purchases Get purchase history for the authenticated user (buyer view). Inputs: - `limit` (number, optional) Response: `{ "count": N, "purchases": [...] }` #### get_my_sales Get sales history for the authenticated user (seller view). Inputs: - `limit` (number, optional) Response: `{ "count": N, "sales": [...] }` --- ### SELLER FINANCIAL TOOLS (5) — bearer + connected Stripe account required These tools require both a valid Bearer token (or API key) AND a connected Stripe account. If no Stripe account is connected, all return: `{ "error": "No Stripe account connected" }` #### Dash's marketplace fee — what a seller pays Dash takes a percentage of every completed listing or branch purchase. The current rate is returned by `seller { action: "financial_summary" }` under `platform_fee.percent`, and by the public endpoint `GET /api/config/public` as `platformFeePercent`. Do not hard-code it. - **Borne by the seller.** Deducted from the payout, never added to what the buyer pays. - **Taken in the transaction currency**, as a Stripe `application_fee_amount` on a DIRECT charge — the payment is created on the seller's own Stripe account, so the seller is the merchant of record. Their business name is what appears on the buyer's card statement, not Dash's. - **Only on purchases.** Creating, editing, verifying and negotiating a listing or branch are all free. Contract execution is billed separately and never appears in a seller payout. - **Stripe's payment processing is charged separately by Stripe** and is not part of Dash's fee. A seller's net receipt is the sale price minus Dash's fee minus Stripe processing. Each purchase records `platform_fee_amount` and `platform_fee_percent` as a snapshot at time of sale, so a later rate change never restates historical payouts. `financial_summary` reports `total_revenue` (gross), `platform_fees`, and `net_revenue`. When telling a seller what they earned, quote **net_revenue** — quoting gross overstates what reached them. #### get_seller_balance Get the seller's current Stripe balance (available and pending). Inputs: none Response: `{ "available": [{ "amount": N, "currency": "usd" }], "pending": [...] }` #### list_seller_payouts List recent payouts to the seller's bank account. Inputs: - `limit` (number, optional) Response: `{ "payouts": [{ "id": "...", "amount": N, "status": "paid", "arrival_date": "..." }] }` #### get_seller_account_status Get Stripe Connect account status and requirements. Inputs: none Response: `{ "status": "active" | "restricted", "details_submitted": true, "requirements": { ... } }` #### list_seller_payments List payments received by the seller. Inputs: - `limit` (number, optional) - `starting_after` (string, optional) — pagination cursor Response: `{ "payments": [{ "id": "...", "amount": N, "status": "succeeded", ... }] }` #### get_financial_summary Get a combined financial summary: balance, recent payouts, and recent payments. Inputs: none Response: `{ "balance": { ... }, "recent_payouts": [...], "recent_payments": [...] }` --- ### MCP RESOURCES Two resources are available for reading current platform data: **dash://listings** — recent active listings feed ```json { "listings": [...] } ``` **dash://categories** — all categories with counts ```json { "categories": [{ "name": "Electronics", "count": 42 }] } ``` Read resources using the MCP `resources/read` method with the URI above. --- ## 4. REST API Routes All routes at `https://go-dash.app`. Auth headers same as MCP. > **REST vs MCP — important differences** > > MCP tool responses and REST responses are **not** the same shape. If you switch > between the two, adjust your parsing: > > | Operation | MCP result | REST response | > |-----------|------------|---------------| > | Create listing | `{ success, listing: { id, paycode, url } }` | `{ success, listing: { _id, paycode, ... } }` | > | Get single listing | direct object `{ id, title, paycode, url, ... }` | `{ success, data: { _id, title, paycode, ... } }` | > | Search listings | `{ count, listings: [...] }` | `{ success, data: [...], pagination: { ... } }` | > | Get own listings | `{ count, listings: [...] }` | `{ success, data: [...], pagination: { ... } }` | > | Get own profile | `{ id, handle, first_name, ... }` via `get_my_profile` | **No route.** Use MCP, or read `data.user` from the login response. `GET /api/user/me/profile` returns 404 and `GET /api/user/me` rejects Cognito tokens with `"invalid algorithm"`. | > > MCP tools use `id` (lowercase, stripped) and always include a `url` field. > REST returns Mongoose documents with `_id` and no `url` field. Paycodes on > REST responses are uppercase without the `#` prefix; MCP adds the `#` prefix. ### Auth ``` POST /api/auth/cognito/login Body: { "email": "...", "password": "..." } Response: { "success": true, "data": { "token": "", "user": { ... } } } ``` ### API Keys ``` POST /api/user/me/api-keys — create key (Bearer required) GET /api/user/me/api-keys — list keys (Bearer required) DELETE /api/user/me/api-keys/:id — revoke key (Bearer required) ``` ### Listings ``` POST /api/listings/create-direct Auth: Bearer or X-API-Key Body: { "title": "...", // required "description": "...", // required "price": 99.00, // required (use 0 if TBD) "seller_handle": "flowerflow", // optional — target seller by handle "category": "Electronics", // optional (default "Other") "condition": "Good", // optional — "New"|"Like New"|"Good"|"Fair"|"Poor" "type": "product", // optional — "product"|"service" "tags": ["tag1", "tag2"], // optional "returns_policy": "...", // optional (max 300 chars) "shipping_policy": "..." // optional (max 300 chars) } Response: { "success": true, "listing": { "_id": "...", "paycode": "ABC123", "title": "...", ... }, "autoRequestCreated": false } ``` NOTE: Use `/api/listings/create-direct`, NOT `/api/listings` — `POST /api/listings` returns a plain-text `Cannot POST /api/listings` 404. NOTE: The response wrapper key is `"listing"`, NOT `"data"` — access via `response.listing._id` and `response.listing.paycode`. NOTE: `seller_handle` accepts a handle string (e.g. `"flowerflow"`), not an email or MongoDB ID. Use `search_sellers` MCP tool (or `GET /api/users/sellers/search?query=`) to find a seller's handle first. NOTE: `condition` values are case-sensitive and must be one of the enum above. Using lowercase like `"new"` will fail schema validation. NOTE: When a buyer creates a listing with `seller_handle`, a request is auto-created but the `request_id` is NOT included in the response. Use `GET /api/listings/:id/timeline` (with the listing's MongoDB ID or paycode) to retrieve the `request_id` from the request event before starting proposals. ``` GET /api/listings/:id — get single listing by ID or paycode Response: { "success": true, "data": { ... listing ... } } NOTE: Wrapper key here is "data", NOT "listing". GET /api/listings?query=...&category=...&limit=20 Response: { "success": true, "data": [ ... listings ... ], "pagination": { ... } } NOTE: Wrapper key here is "data" (array), NOT "listings". GET /api/listings/user/own-direct Response: { "success": true, "data": [ ... listings ... ], "pagination": { ... } } Returns listings owned by the authenticated user. ``` ### Listing Timeline ``` GET /api/listings/:id/timeline :id accepts MongoDB ID or paycode (with or without #) Auth: Bearer or X-API-Key Response: { "data": [ { "type": "request", "user": { "handle": "..." }, "created_at": "..." }, { "type": "edited", "metadata": { "changed_fields": [...], "from_revision": 2, "to_revision": 3 }, ... }, { "type": "accepted", "metadata": { "at_revision": 3 }, ... }, { "type": "commented", "metadata": { "comment_id": "..." }, ... } ] } ``` The timeline is the audit trail of the lifecycle. Edit events carry the revision they moved from and to, so you can reconstruct exactly how the terms evolved and who moved them. ### Real-time events (SSE) — REST only, no MCP tool ``` GET /api/events/stream?resource= Auth: Bearer or X-API-Key (same credentials as any other call) Response: text/event-stream, connection held open ``` Subscribe to a listing, branch, or contract instead of polling its timeline. The resource param accepts the same identifiers as everywhere else: a plain paycode ("O1A8DO"), a branch branch code ("O1A8DO/B2"), or a contract code ("CTR8PZNK"). The `#` prefix is optional. This is a raw HTTP endpoint, not an MCP tool — MCP tool calls are single request/response and cannot hold a long-lived stream open. Call it directly with the same Bearer token or X-API-Key you use for MCP/REST calls (see "Raw HTTP to /mcp" in section 2 for the same pattern applied to tool calls). Access follows the same visibility rules as reading the resource itself: - A public (`searchable: true`) listing — anyone, including unauthenticated requests. - A draft listing not yet searchable — only its seller, or admin. - A branch — only the branch's buyer and seller (never public), or admin. - A contract — only the contract's creator and counterparty, or admin. An unrecognised resource returns 404 `{ "error": "resource_not_found" }`. A resource that exists but you can't see returns 403 `{ "error": "forbidden" }`. Each event on the stream: ``` event: change_proposed data: {"type":"change_proposed","actor":{...},"timestamp":"...","metadata":{"changed_fields":["price"],"proposed_changes":{"price":{"old":110,"new":100}}}} ``` `type` is the literal Activity `type` value (see the enum in `activity.model.js`) — this stream and the timeline endpoint read from the same underlying record, just pushed live instead of polled. There is NO `revision` field on the event — Activity doesn't track it (only the Listing/Contract document does). If you need the current revision, read it off the resource (`get_listing`/`get_contract`) after the event arrives, same as you already must with the timeline endpoint. The names do NOT match the MCP lifecycle verbs 1:1 — most relevant for a branch or listing negotiation: - `branch_created` — a branch was opened (the proposal) - `change_proposed` — an edit (including a counter-offer) was made - `change_approved` — a party accepted the current revision - `branch_verified` — the seller finalised (verified) the branch - `branch_purchased` — the branch was purchased For a contract: `contract_created`, `contract_edited`, `contract_accepted`, `contract_status_changed`. The server sends a `: heartbeat` comment every 25 seconds to keep the connection alive. Reconnect (with backoff) if the stream drops — there is no resume/replay cursor yet, so re-fetch the timeline for anything missed while disconnected. **Workflow — watch a negotiation instead of polling:** ``` # After opening or being tagged on a branch, hold a stream open on it: GET /api/events/stream?resource=O1A8DO/B1 # Evaluate each event against your policy as it arrives, e.g. on change_proposed: # compare metadata.proposed_changes against your budget/floor, then call # listing { action: "edit"|"accept", ... } in response. ``` ### Listing Actions (Requests, Approval, and Proposals) ``` POST /api/listings/:id/request-direct — request listing (Bearer) POST /api/listings/:id/unrequest-direct — cancel request (Bearer) POST /api/listings/:id/approve — approve listing as-is (Bearer, seller only) No body required. Seller approves the listing without changes. If seller is verified (Stripe connected), auto-cascades to verified. Response: { "success": true, "message": "Listing approved and verified", "listing": {...}, "verified": true } If seller is not Stripe-connected: Response: { "success": true, "message": "Listing approved. Connect a Stripe account to enable verification.", "listing": {...}, "verified": false } (There is no `POST /api/listings/:id/verify` — verify IS finalise; see `/:code/finalise` below. Requires stripe_authenticated=true; returns 403 if the seller is not Stripe-connected.) Lifecycle routes — the same five verbs as MCP. POST /api/listings/:code/propose — draft -> in_proposal (Bearer) Body: { "expected_revision": 1 } (optional) PUT /api/listings/:code — EDIT terms (Bearer) Body: { "price": 150, "title": "...", "expected_revision": 1 } Editable: name, title, price, description, tags, returns_policy, shipping_policy Bumps `revision` by 1 and stales every other party's acceptance. POST /api/listings/:code/accept — accept the current revision (Bearer) Body: { "expected_revision": 1 } (optional) Does NOT bump the revision. POST /api/listings/:code/finalise — seller verifies (Bearer, Stripe-connected seller) Body: { "expected_revision": 1 } (optional) Sets verified: true and makes the listing purchasable. POST /api/listings/:code/close — end it (Bearer) Body: { "reason": "withdrawn" | "rejected" | "voided", "expected_revision": 1 } POST /api/listings/:code/status — toggle active/inactive (Bearer, seller) All five echo the re-resolved view: `{ revision, phase, can, waiting_on, purchasable, ... }`. A stale `expected_revision` returns **HTTP 409** `{ "error": "revision_conflict", "expected_revision", "actual_revision", "current" }` — the REST equivalent of the MCP `revision_conflict` (which is not an isError). Contracts expose the identical set under `/api/contracts/:code/...`. ``` ### Collections ``` POST /api/collections — create collection (Bearer) Body: { "name": "...", "visibility": "public"|"private" } GET /api/collections — list own collections (Bearer) GET /api/collections/public — browse public collections GET /api/collections/:id — get collection by ID or paycode PATCH /api/collections/:id — update collection (Bearer) Body: { "name": "...", "status": "active"|"archived", "visibility": "public"|"private" } DELETE /api/collections/:id — delete collection (Bearer) POST /api/collections/:id/listings — add listings (Bearer) Body: { "listingIds": ["..."] } DELETE /api/collections/:id/listings/:listingId — remove listing (Bearer) Records a listing_removed timeline event (visible to all). POST /api/collections/:id/purchase-all — purchase every listing (Bearer) Requires: all listings in the collection must be verified. Requires: buyer must have a saved payment method and billing address. Each listing is charged separately (one Stripe PaymentIntent per listing). Each purchase creates an individual Purchase record. Partial success allowed — failures reported per-listing, others still complete. Response: { "success": true, "purchased": 3, — number of successful charges "total": 3, — total listings attempted "amount": 447.00, — total amount charged (AUD) "currency": "AUD", "results": [ { "listing_id": "...", "success": true, "payment_intent_id": "pi_...", "amount": 149.00 }, { "listing_id": "...", "success": false, "error": "Card declined" } ] } Errors: 400 — "Collection has no listings" 400 — "{N} listing(s) not verified. All listings must be verified to purchase a collection." 400 — "Please add a billing address in your profile before making a purchase" 400 — "No payment method found. Please add a payment method in your profile first." GET /api/collections/:id/timeline — get collection timeline (optional Bearer) Public events visible to all. Personal events (like, purchase_all) returned only when authenticated as the user who performed them. Response: { "success": true, "data": [ { "type": "listing_removed", "createdAt": "...", "metadata": { "listing_name": "...", "listing_id": "..." } }, { "type": "like", "createdAt": "...", "actor": { "handle": "..." } }, { "type": "purchase_all", "createdAt": "...", "metadata": { "amount": 447.00, "listing_count": 3 } } ] } Timeline event types: listing_removed — a listing was removed from the collection (visible to all) like — the authenticated user liked the collection (visible only to that user) purchase_all — the authenticated user purchased all listings (visible only to that buyer) Note: collection created and listing added events are derived from the collection document itself (collection.createdAt and collection.listings[].added_at), not from this endpoint. The frontend merges both sources and sorts newest-first. PATCH /api/collections/:id/like-direct — toggle like (Bearer) Response: { "success": true, "liked": true|false, "likes": N } GET /api/collections/:id/like-status — check like status (optional Bearer) Response: { "success": true, "liked": true|false } ``` ### Contracts ``` POST /api/contracts — create contract GET /api/contracts — get my contracts GET /api/contracts/:code — get single contract (echoes revision + can[]) Lifecycle — the same five verbs as listing/branch. There is NO `PATCH /:code/status`: phase is DERIVED from the lifecycle events, never set directly. POST /api/contracts/:code/propose — draft -> in_proposal PUT /api/contracts/:code — EDIT sections/title. Body: { "sections": [{ "name": "...", "text": "..." }], "expected_revision": 2 } Bumps revision by 1 and stales the other party's acceptance. POST /api/contracts/:code/accept — accept the current revision (does NOT bump it) POST /api/contracts/:code/finalise — EXECUTE. Consensus gate: refused unless every required party has accepted the current revision. POST /api/contracts/:code/close — Body: { "reason": "withdrawn"|"rejected"|"voided" } GET /api/contracts/:code/revisions — revision history GET /api/contracts/:code/revisions/:rev — a single revision snapshot GET /api/contracts/:code/timeline — event timeline POST /api/contracts/:code/comments — add comment (does not touch revision or phase) ``` All lifecycle routes take an optional `expected_revision` and return **HTTP 409** `{ "error": "revision_conflict", "expected_revision", "actual_revision", "current" }` if the terms moved under you. A finalised contract is never purchasable. --- ## 5. Common Workflows ### 1. Search and explore (no auth) ``` search_listings({ "query": "standing desk", "max_price": 500 }) get_listing({ "listing_id": "ABC123" }) get_seller_profile({ "handle": "flowerflow" }) browse_collections({ "query": "office" }) ``` ### 2. Create a listing and add to collection ``` # Step 1: Create listing create_listing({ "title": "Standing Desk", "description": "Adjustable height, 60 inch", "price": 349.00, "category": "Furniture", "condition": "New", "type": "product" }) # -> result.listing._id, result.listing.paycode # Step 2: Create or fetch collection create_collection({ "name": "Office Setup", "description": "Home office bundle" }) # -> result.collection._id # Step 3: Add listing to collection add_to_collection({ "collection_id": "", "paycode": "" }) ``` ### 3. Build a quote (bulk listings + collection) ``` # Step 1: Bulk create related listings create_listings_bulk({ "listings": [ { "title": "Monitor", "price": 299, "category": "Electronics", "condition": "New", "type": "product", "description": "27 inch 4K" }, { "title": "Keyboard", "price": 89, "category": "Electronics", "condition": "New", "type": "product", "description": "Mechanical" }, { "title": "Mouse", "price": 49, "category": "Electronics", "condition": "New", "type": "product", "description": "Wireless" } ] }) # -> result.listings (array with _id and paycode for each) # Step 2: Create collection create_collection({ "name": "Desktop Bundle Q2", "description": "Full desktop setup quote" }) # Step 3: Add all at once (paycodes array) add_to_collection({ "collection_id": "", "paycodes": ["", "", ""] }) ``` ### 4. Purchase all listings in a collection ``` # Prerequisite: all listings in the collection must be verified (verified: true) # Check before calling: collection = get_collection({ "collection_id": "" }) unverified = collection.listings.filter(l => !l.verified) # If unverified.length > 0, the purchase-all endpoint will return 400 # Purchase all (REST — no MCP tool yet) POST /api/collections//purchase-all Authorization: Bearer # Response (all succeeded): { "success": true, "purchased": 3, "total": 3, "amount": 447.00, "currency": "AUD", "results": [...] } # Response (partial — one card decline): { "success": true, "purchased": 2, "total": 3, "amount": 298.00, "currency": "AUD", "results": [ { "listing_id": "...", "success": true, ... }, { "listing_id": "...", "success": true, ... }, { "listing_id": "...", "success": false, "error": "Your card was declined." } ] } # After success: each purchased listing appears as a BOUGHT row on Account > Transactions # individually — same shape as a direct single-listing purchase. # A purchase_all timeline event is recorded on the collection (buyer-only visibility). ``` ### 5. Seller approves listing as-is (no proposal exchange) ``` # Step 1: Buyer creates a listing tagging a seller create_listing({ "title": "Custom Logo Design", "price": 200, "seller_handle": "designpro", ... }) # -> listing._id, listing.paycode, verified: false # Step 2: Seller approves the listing without changes (via REST endpoint) # POST /api/listings//approve (Bearer auth) # -> If seller is Stripe-connected: listing approved + verified automatically # -> If not: listing approved but remains unverified until seller connects Stripe ``` ### 6. Propose changes listing terms (proposal workflow) Full lifecycle on a two-party listing. There is no turn order to get right — either party may edit at any time. No `request_id` is involved. ``` # Step 1: Buyer creates the listing addressed to a seller listing({ "action": "create", "title": "Custom Logo Design", "price": 200, "seller_handle": "@theseller", ... }) # -> listing.paycode, phase: "draft", revision: 1, verified: false # Step 2: Send it to the seller listing({ "action": "propose", "listing_id": "" }) # -> phase: "in_proposal", revision: 1, waiting_on: "@theseller" # Proposing implicitly accepts for the proposer — no revision bump. # Step 3: Either party edits. A counter is just another edit. listing({ "action": "edit", "listing_id": "", "changes": { "price": 250, "description": "Updated scope..." }, "expected_revision": 1 }) # -> revision: 2, waiting_on: the OTHER party # The editor is now accepted at revision 2; everyone else went stale automatically. # Step 3a: Change your mind? Just edit again — there is no "self-edit rule", # no lock, and no turn to wait for. listing({ "action": "edit", "listing_id": "", "changes": { "price": 240 }, "expected_revision": 2 }) # -> revision: 3 # Step 4: The outstanding party accepts (does NOT bump the revision) listing({ "action": "accept", "listing_id": "", "expected_revision": 3 }) # -> phase: "agreed", purchasable: false # agreed != purchasable. Verify is a separate gate. # Step 5: The seller verifies (finalise). Seller only. listing({ "action": "finalise", "listing_id": "", "expected_revision": 3 }) # -> phase: "finalised", verified: true, status: "active", purchasable: true # Terms are now frozen: edit/propose/accept all drop out of `can`. # Instead of accepting, either party may end it: listing({ "action": "close", "listing_id": "", "reason": "rejected" }) # -> phase: "closed". Read `close_reasons` from a get to see which are available to you. ``` ### 7. Create a contract and agree its terms ``` # Step 1: Creator drafts the contract # Section fields are "name" and "text". contract({ "action": "create", "title": "Service Agreement — Logo Design", "counterparty_handle": "@clienthandle", "sections": [ { "name": "Scope", "text": "Design a logo in 3 rounds of revisions." }, { "name": "Payment", "text": "250 USD on completion." }, { "name": "Timeline", "text": "Delivered within 14 days." } ] }) # -> { contract: { id, contractcode: "CTRKAVRD", revision: 1, phase: "draft", parties, sections, url }, # revision: 1, phase: "draft", can: ["edit","propose","close"], next_steps: [...] } # Use contract.contractcode as contract_id in every subsequent call. # Step 2: Creator sends it to the counterparty contract({ "action": "propose", "contract_id": "CTRKAVRD" }) # -> phase: "in_proposal", revision: 1, waiting_on: "@clienthandle" # Proposing implicitly accepts for the proposer. No revision bump. # Step 3: Either party edits. Both sides may edit at any time — no turn lock, # and a counter-proposal is just an edit. contract({ "action": "edit", "contract_id": "CTRKAVRD", "sections": [{ "name": "Payment", "text": "250 USD: 50% up front, 50% on completion." }], "expected_revision": 1 }) # -> revision: 2, waiting_on: the OTHER party # The editor is accepted at revision 2; everyone else's acceptance went stale. # Step 4: The outstanding party accepts the current revision contract({ "action": "accept", "contract_id": "CTRKAVRD", "expected_revision": 2 }) # -> phase: "agreed" (every required party is now caught up at revision 2) # Step 5: Execute. Contracts use the CONSENSUS gate — this is refused unless # phase is already "agreed". The verb surfaces as "execute", not "verify". contract({ "action": "finalise", "contract_id": "CTRKAVRD", "expected_revision": 2 }) # -> phase: "finalised", purchasable: false # A contract is never purchasable — it is an agreement, not a transaction. # To end it instead: contract({ "action": "close", "contract_id": "CTRKAVRD", "reason": "rejected" }) # -> phase: "closed". A get lists which reasons are available to you in close_reasons. ``` ### 8. Seller financial overview ``` # Check account connection get_seller_account_status() # -> { "status": "active", "details_submitted": true } # Get balance get_seller_balance() # -> { "available": [{ "amount": 15000, "currency": "usd" }], "pending": [...] } # Full summary get_financial_summary() # -> { "balance": {...}, "recent_payouts": [...], "recent_payments": {...} } ``` ### 9. Create a contract from free-text clauses User prompt example: "Create a draft contract on Dash. Here are the clauses: [text]. The counterparty is john@example.com." **Step 1 — Resolve the counterparty handle** Contracts require a Dash handle, not an email. Handles are available to all users — not just sellers — but `search_sellers` only returns seller accounts. **If the counterparty has a Dash account:** 1. Try `search_sellers({ query: "" })` — works if they are a seller. 2. If found, confirm with the user: "Is @{handle} the right person?" 3. If not found (they may be a buyer), ask: "What is your counterparty's Dash handle? (e.g. @johnsmith)" **If the counterparty does NOT have a Dash account yet:** Non-user counterparties are fully supported. The contract is created immediately and the counterparty claims it by signing up with the matching handle. 1. Suggest a handle derived from their email: strip everything from `+` or `@` onwards, lowercase, remove non-alphanumeric chars. E.g. `tjcarlton+testing6@example.com` → `tjcarlton6`. 2. Tell the user: "Your counterparty isn't on Dash yet. I'll reserve the handle @{suggested} for them — they'll see this contract once they sign up with that handle. Shall I use @{suggested}, or would you like a different handle?" 3. Wait for confirmation before proceeding. Do not call `create_contract` until the handle is confirmed. **Step 2 — Parse clauses into sections** If the user gave raw text without section headings, divide it logically and assign clear titles: | Clause content | Suggested section title | |---|---| | What work will be done | "Scope of Work" | | Payment amount or schedule | "Payment Terms" | | Deadline or delivery date | "Timeline" | | Confidentiality / NDA | "Confidentiality" | | Termination conditions | "Termination" | | Governing law / jurisdiction | "Governing Law" | | Any other clause | Use the clause's natural heading | If the user provided their own headings, use them as-is. **Step 3 — Show pre-creation confirmation** Before calling `create_contract`, display the pre-creation confirmation format from §7 and wait for the user to say yes. Do not proceed without confirmation. **Step 4 — Create the contract** ``` # Section fields are "name" and "text" — NOT "title"/"content" create_contract({ "title": "{agreed title, e.g. 'Service Agreement — Logo Design'}", "counterparty_handle": "{confirmed handle}", "sections": [ { "name": "Scope of Work", "text": "..." }, { "name": "Payment Terms", "text": "..." } ] }) # -> result.contract._id, status: "draft" ``` **Step 5 — Display the result** Use the post-creation display format from §7 — compact header, parties line (use email if counterparty has no handle), all sections, link, and offer to propose. If `invited_email` is present in the response, add one line: > Invite sent to {invited_email} — they can sign in directly from the email to claim @{handle}. --- ### 10. Contract — full flow Covers every phase from draft to execution, with compact display at each step. **Never derive what to do next from a table.** Every response carries `can` — the exact actions available to you right now — plus `waiting_on` and `next_steps`. `can` is also the write-side's authorisation source, so if an action is not in `can`, the call will be refused. Phase is derived: `draft` -> `in_proposal` -> `agreed` -> `finalised` (or `closed`). | Phase | Typically in `can` | |---|---| | `draft` | creator: `edit`, `propose`, `close` | | `in_proposal` | either party: `edit`, `accept`, `close` | | `agreed` | either party: `finalise`, `edit`, `close` | | `finalised` / `closed` | nothing — terminal | Editing at any phase before `finalised` bumps `revision` and stales every other party's acceptance. Editing implicitly accepts for the editor. --- **Step A — Creator proposes (draft -> in_proposal)** ``` contract({ "action": "propose", "contract_id": "CTRKAVRD" }) ``` Display: ``` **Partnership Agreement** · `in proposal` · revision 1 @alexchen → @tjcarlton6 · 17 May 2026 Sent to @tjcarlton6 for review. https://go-dash.app/contract/CTRKAVRD ``` --- **Step B — A party edits sections (new revision, implicit acceptance for the editor)** ``` contract({ "action": "edit", "contract_id": "CTRKAVRD", "sections": [ { "name": "Payment Terms", "text": "50% on signing, 50% on delivery." } ], "summary": "Added payment schedule", "expected_revision": 1 }) ``` After the call, diff the returned `contract.sections` against what you previously held. Show only what changed — summarise unchanged sections on one line. Display format: ``` **Partnership Agreement** · `in proposal` · revision 2 @alexchen → @tjcarlton6 ~ **Payment Terms** 50% on signing, 50% on delivery. _(was: Payment of $500 on completion.)_ Unchanged: Revenue Sharing · Intellectual Property Waiting on @tjcarlton6 — this change superseded their earlier acceptance. https://go-dash.app/contract/CTRKAVRD ``` Rules for the diff: - `~` prefix = section text changed (show new text, then _(was: ...)_ on the next line) - `+` prefix = new section added (show name and text) - `-` prefix = section removed (show name only) - Unchanged sections: list names inline separated by ` · `, no body text - If all sections are unchanged, write: "No section changes in this revision." - Keep _(was: ...)_ to one sentence or the first 120 chars — truncate with `...` if longer To compute the diff, compare `contract.sections` from the response against the previous state. If you don't hold it, call `contract({ action: "revisions" })` and diff revision N against N-1. --- **Step C — The outstanding party accepts (-> agreed)** Only the party who has not already accepted the current revision needs to act — the last editor is already accepted at that revision. `accept` does not bump `revision`. ``` contract({ "action": "accept", "contract_id": "CTRKAVRD", "expected_revision": 2 }) ``` Display while one party is still outstanding: ``` **Partnership Agreement** · `in proposal` · revision 2 @alexchen → @tjcarlton6 · 17 May 2026 @alexchen has accepted revision 2. Waiting on @tjcarlton6. https://go-dash.app/contract/CTRKAVRD ``` Display once every required party is caught up: ``` **Partnership Agreement** · `agreed` · revision 2 @alexchen → @tjcarlton6 · 17 May 2026 Both parties have accepted revision 2. Either party can now execute. https://go-dash.app/contract/CTRKAVRD ``` --- **Step D — Execute (agreed -> finalised)** Contracts use the **consensus** gate: `finalise` is refused unless the phase is already `agreed`. The verb surfaces as **execute** (`finalise_verb: "execute"`). ``` contract({ "action": "finalise", "contract_id": "CTRKAVRD", "expected_revision": 2 }) ``` Display: ``` **Partnership Agreement** · `executed` · revision 2 @alexchen → @tjcarlton6 · 17 May 2026 Contract executed. Terms are now frozen. https://go-dash.app/contract/CTRKAVRD ``` --- **Ending a contract** One `close` action, with the reason recording who ended it and why. A `get` lists the reasons available to you in `close_reasons`. ``` contract({ "action": "close", "contract_id": "CTRKAVRD", "reason": "rejected" }) ``` | reason | who | |---|---| | `withdrawn` | the creator retracts | | `rejected` | a required counterparty declines | | `voided` | either party cancels | Display: ``` **Partnership Agreement** · `closed` — rejected @alexchen → @tjcarlton6 · 17 May 2026 Contract rejected by @tjcarlton6. https://go-dash.app/contract/CTRKAVRD ``` --- **Checking whose turn it is** Read it, do not derive it. Every contract response carries: - `can` — what you may do now - `waiting_on` — the party who is outstanding (null if nobody is) - `action_needed` — true if that party is you - `revision` — pass it back as `expected_revision` on your next write Always surface `next_steps` and the available actions to the user after showing the state. --- ## 6. Error Handling ### Error response patterns Tool returns `isError: true`: ```json { "isError": true, "error": "Authentication required", "message": "..." } ``` Soft error (no `isError` but `"error"` key in result): ```json { "error": "No Stripe account connected" } ``` Always check for both patterns before assuming success. ### Common errors | Error message | Cause | Fix | |---|---|---| | `"Authentication required"` | Missing or expired token | Re-authenticate, get new Bearer token | | `{ "error": "revision_conflict", ... }` | The terms moved under you — you passed a stale `expected_revision` | **Not a failure** (`isError` is false). Read `current` from the response and retry with `actual_revision`. REST returns 409. | | `"Cannot 'edit' -- phase is 'finalised', your role is 'seller'"` | Tried an action not in your `can` list | Read `can` from a `get` — it is the authoritative list of what you may do now | | `"This listing has been verified and can no longer be changed"` | Edit on a finalised listing | Terms freeze on finalise. Create a new listing, or open a branch | | `"Only verified sellers can verify listings"` | verify_listing called by non-Stripe seller | Seller must connect Stripe via web app first | | `"You can only approve listings tagged to you as the seller"` | approve called by non-seller | Only the tagged seller can approve | | `"No Stripe account connected"` | Financial tool, no Stripe | Seller must connect Stripe via web app | | `"Not Acceptable"` | Missing Accept header on raw HTTP | Add `Accept: application/json, text/event-stream` | | `"Cannot create contract with yourself"` | Same user on both sides | Use a different counterparty handle | ### Token expiry Bearer tokens expire after ~1 hour. Symptom: any bearer-authenticated tool call returns `"Authentication required"`. Fix: re-POST to `/api/auth/cognito/login` and use the new `data.token`. API keys do not expire. ### HTTP status codes (REST API) | Code | Meaning | |---|---| | 400 | Bad request — missing or invalid fields | | 401 | Unauthorized — missing or invalid auth header | | 403 | Forbidden — authenticated but not allowed (wrong role, wrong turn) | | 404 | Not found — listing, collection, or contract ID does not exist | | 500 | Server error — retry once; if persistent, contact admin@go-dash.app | --- ## 7. Displaying Results ### Listing card Use this format whenever presenting a listing to a user — especially before opening a branch. ``` `@{seller.handle}` · `#{paycode}` **{title}** — **${price} AUD** ``` Example: `@flowerflow` · `#TFRV6V` **Classic Unisex Hoodie** — **$89 AUD** ### Variable selection — confirm before create_branch When a listing has `variables[]` and `variable_sets[]`, present the available choices compactly and wait for the user to confirm before calling `create_branch`. Never guess a variable_id. **How to build the display:** 1. Read `variables[]` — each entry has `name` and `values[]`. Render one line per variable: `**{name}:** val1 · val2 · val3` 2. Scan `variable_sets[]` to detect: - **Constraints** — values that don't appear across all combinations (e.g. a colour only offered in certain sizes). List them in a callout below the variables. - **Price differences** — variable_sets where `price` differs from the base listing price. Note the price alongside the affected values. 3. Surface constraints and price differences in a `>` blockquote beneath the variables, then ask the user to confirm their selection. Do not expand every variable_id row. **Template:** ``` `@{seller.handle}` · `#{paycode}` **{title}** — **${base_price} AUD** **{variable_1.name}:** {variable_1.values joined by " · "} **{variable_2.name}:** {variable_2.values joined by " · "} ...one line per variable group... > {any constraints or price differences, e.g. "Red and Forest Green: M and L only · $92"} Which {variable names} would you like? ``` **Example output:** `@flowerflow` · `#TFRV6V` **Classic Unisex Hoodie** — **$89 AUD** **Size:** S · M · L · XL **Colour:** Black · Charcoal · Red · Forest Green > Red and Forest Green available in M and L only · $92 Which size and colour would you like? **After the user confirms:** - Resolve the `variable_id` from `variable_sets[]` using the confirmed values - Pass `selected_options` (the variables dict) and `matched_variant_sku` (the variable_id) in the `create_branch` intent - If the selection doesn't map to a variable_set, say so and ask the user to choose again rather than calling `create_branch` with an invalid combination ### Listing created After `create_listing` or `update_listing` — show a compact confirmation. For updates, list what changed. **After create_listing:** ``` Listing created on Dash. `@{seller.handle}` · `#{paycode}` **{title}** — **${price} AUD** {type} · {condition} · {category} Status: {status}{verified ? " (verified — ready to purchase)" : " (unverified — connect Stripe to verify)"} {variables.length > 0 ? "**" + variables.map(v => v.name + ":** " + v.values.join(" · ")).join("\n**") : ""} https://go-dash.app/listing/{paycode} ``` **After update_listing** — show only what changed: ``` Listing updated. `@{seller.handle}` · `#{paycode}` — **{title}** Changed: {for each updated field: "**{field}:** {new value}"} ``` Example: ``` Listing updated. `@alexchen` · `#5A5T4R` — **Handmade Ceramic Mug** Changed: **Description:** Wheel-thrown stoneware mug with food-safe glaze. Each piece is hand-finished… **Returns policy:** Exchanges accepted within 14 days for manufacturing defects. **Tags:** ceramic, handmade, mug, stoneware, gifts, kitchenware ``` ### Branch created confirmation After `create_branch` succeeds, show this to confirm the branch with the user: ``` Branch opened on Dash. `{branch_code}` — **{parent listing title}** {intent.selected_options entries as "**Key:** Value" joined by " · "} Price: ${intent.target_price || parent.price} AUD{intent.qty > 1 ? " · Qty: " + intent.qty : ""} Status: open {url} ``` Then add one sentence on what happens next: the seller will review this branch and may propose changes before it can be purchased. ### Branch change display After an `edit`, or when showing changes the other party made. Show a compact diff — new values only, field label in bold. Drive the "what next" line from `can` and `waiting_on`, never from a guess. **You edited (waiting on the other party):** ``` Terms updated on `{branch_code}` — now revision {revision}. {for each changed field: "**{field}:** {new value}"} Waiting on {waiting_on}. Their earlier acceptance was superseded by this change. ``` **They edited (your turn):** ``` **{branch_code}** — {title} Changes from @{editor handle} (revision {revision}): {for each changed field: "**{field}:** {new value}"} You can: {can, e.g. "accept these terms, edit them further, or close"} ``` **Agreed (both caught up, not yet verified):** ``` Both parties agreed on `{branch_code}` at revision {revision}. **{title}** — **${price} AUD** Waiting on the seller to verify before it can be purchased. ``` **Finalised (`finalise` → `verified: true`):** ``` Verified: `{branch_code}`. **{title}** — **${price} AUD** Ready to purchase. {url} ``` ### Purchase confirmation After `POST /api/purchase/direct-charge` with `{ "listing_id": "" }`: **Success:** ``` Purchase complete. **{title}**{branch ? " · `{branch_code}`" : " · `#{paycode}`"} ${amount} AUD charged {seller: "@{seller.handle}"} Ref: {payment_intent_id} ``` **Error — seller not Stripe-connected:** ``` Purchase failed: the seller has not connected a payment account yet. The listing needs to be verified by a Stripe-connected seller before it can be purchased. ``` **Error — buyer has no payment method:** ``` Purchase failed: no saved payment method on your account. Add a payment method at https://go-dash.app/settings/payments then try again. ``` ### Listing detail Display this when a user asks to view or inspect a specific listing. Omit any line whose value is empty or null. ``` `@{seller.handle}` · `#{paycode}` **{title}** — **${price} AUD** {type} · {condition} · {category} Status: {status}{verified ? " (verified)" : " (unverified)"} {description} {shipping_policy ? "**Shipping:** " + shipping_policy : ""} {returns_policy ? "**Returns:** " + returns_policy : ""} {tags.length ? "**Tags:** " + tags.join(", ") : ""} View: https://go-dash.app/listing/{paycode} ``` ### Search results ``` Found {count} listings for "{query}": 1. {listing.title} — ${listing.price} — @{listing.seller.handle} — #{listing.paycode} 2. ... ``` ### Collection summary ``` Collection: {collection.name} (#{collection.paycode}) Owner: @{collection.owner.handle} Visibility: {collection.visibility} — "public" | "private" Listings: {collection.listings.length} items Total: ${sum of listing prices} Purchasable: {all listings verified ? "Yes — Purchase All available" : "No — unverified listings present"} ``` ### Contract detail Display this after creating a contract or when showing an existing one. Always show section content — never just a count. Sections are compact: name bold, text below, one blank line between sections — no dividers. Contract ref: contracts have their own short identifier (e.g. `CTRKAVRD`) distinct from listing paycodes. The MCP response returns `contract.paycode` for this value and `contract.id` (no underscore) for the MongoDB ObjectId. **Post-creation display:** ``` Draft contract created on Dash. **{contract.title}** `{status}` · @{creator.handle} → @{counterparty.handle || counterparty.email} {created_at formatted as "D Month YYYY"} **{section1.name}** {section1.text} **{section2.name}** {section2.text} https://go-dash.app/contract/{contract.paycode} ``` **When retrieving an existing contract:** ``` **{contract.title}** `{status}` · @{creator.handle} → @{counterparty.handle || counterparty.email} {created_at formatted as "D Month YYYY"} **{section1.name}** {section1.text} **{section2.name}** {section2.text} https://go-dash.app/contract/{contract.contractcode} ``` Phase values (derived): `draft` · `in_proposal` · `agreed` · `finalised` · `closed` If the counterparty has no Dash handle (non-user placeholder), show their email instead of @handle in the parties line. **Pre-creation confirmation** — show this *before* calling `create_contract` and wait for the user to confirm. Do not call `create_contract` until confirmed. ``` Ready to create this contract on Dash: **{proposed title}** Counterparty: @{counterparty_handle} (or email if non-user) **{section1.name}** {section1.text} **{section2.name}** {section2.text} Shall I create this as a draft? ``` **Post-creation next-step prompt** — after showing the contract, always offer: > When you're ready, I can propose this to @{counterparty.handle} for their review. --- ## 8. Rules and Limits ### Limits - Collections: max 20 listings per collection; visibility is "public" or "private" (default private) - Collection purchase-all: requires all listings verified; buyer needs saved payment method + billing address; partial success allowed — each listing charged independently - `create_listings_bulk`: max 10 listings per call - API keys: max 5 active keys per user; prefix format `dash_sk_`; SHA-256 hashed before storage - Paycodes: 6-8 uppercase alphanumeric characters; displayed with # prefix; pass with or without # - MongoDB IDs: 24 hex characters; most tools accept either MongoDB ID or paycode - `collection_id` in collection tools requires MongoDB ID (not paycode) - Bearer tokens expire ~1 hour — re-auth with `POST /api/auth/cognito/login`, use `data.token` ### Idempotent operations - `like_listing` — toggle; calling again unlikes - `like_collection` — toggle; calling again unlikes - `request_listing` — idempotent; calling again on the same listing returns the existing request ### Rules for proposing changes - **There is no turn order.** Either party may `edit` at any time while the phase is `in_proposal`. An in-flight proposal is never locked, and there is no "self-edit rule". - **Only `edit` bumps `revision`.** `propose`, `accept`, `finalise` and `close` do not. - **Acceptance is a pointer, not a flag.** Each party stores `accepted_at_revision`; an edit bumps `revision` and therefore stales everyone else's acceptance automatically. Nothing is reset by hand. - **Editing implicitly accepts for the editor** — the last party to edit is never asked to accept again. - **`agreed` = every required party's pointer == the current `revision`.** It is derived, never stored. - **`agreed` is not purchasable.** For a listing/branch, only the seller's `finalise` (verify) sets `verified: true`; buyer `accept` alone never verifies. - **Read `can`, never infer.** `can` is both the UI's button list and the write-side's authorisation source — if an action is not in `can`, the call will be refused. - Once finalised, terms are frozen: `edit`/`propose`/`accept` drop out of `can`. Create a new listing or open a branch instead. ### Verification rules - **Verify IS finalise** — one action, two names. MCP: `listing { action: "finalise" }` (`listing { action: "verify" }` is kept as an alias). REST: `POST /api/listings/:code/finalise`. There is no `/approve` route and no separate approval step. - The seller alone verifies a listing or branch (`finalise_gate: "finaliser"`). A buyer's `accept` never verifies. - Only Stripe-connected sellers (stripe_authenticated=true) can verify — 403 otherwise. - Seller-created listings auto-verify at creation if the seller is Stripe-connected, going straight to `phase: "finalised"`. A solo draft listing offers `finalise` but not `propose` — there is nobody to propose to. - Timeline tracks distinct events with their own timestamps: created -> proposed -> edited / accepted -> finalised. ### Seller handle in create-direct `seller_handle` accepts the handle string (e.g. `"flowerflow"`), not an email or MongoDB ID. Use `search_sellers` MCP tool to find the correct handle before calling `create-direct`. ### Contract rules - Cannot create a contract with yourself (creator and counterparty must be different users) - Contract sections can only be edited before the contract is finalised or closed (phase `draft`, `in_proposal`, or `agreed`) - Status transitions follow strict workflow — see Section 3 Contract Tools for who can transition ### What NOT to do - Do not access `.paycode` directly on `create_listing` response — it is nested under `.listing` (correct: `result.listing.paycode`, wrong: `result.paycode`) - Do not access `._id` directly on `create_collection` response — it is nested under `.collection` (correct: `result.collection._id`, wrong: `result._id`) - Do not wait for "your turn" before editing — there is no turn lock; read `can` instead - Do not treat a `revision_conflict` as a failure — `isError` is false, `current` holds the fresh state, retry against it - Do not `edit` after the listing is finalised — terms are frozen; `edit` will not be in `can` - Do not POST to `/api/listings` — this route does not exist; use `/api/listings/create-direct` - Do not expect `.data` on `create-direct` REST response — the wrapper key is `.listing`. Note: `GET /api/listings/:id` and the search endpoint do use `.data` as their wrapper. - Do not call `GET /api/user/me/profile` — this route does not exist (returns plain text `Cannot GET /api/user/me/profile`). Use `get_my_profile` MCP tool or read `data.user` from the login response. - Do not call `GET /api/user/me` expecting profile data — it uses a different auth layer and rejects Cognito JWTs with `"Authentication failed: invalid algorithm"`. - Do not send `condition` values in lowercase — the schema enum is case-sensitive and only accepts `"New" | "Like New" | "Good" | "Fair" | "Poor"`. - Do not omit `Accept: application/json, text/event-stream` in raw HTTP MCP calls — the server returns `{ "error": { "code": -32000, "message": "Not Acceptable: Client must accept both application/json and text/event-stream" } }` - Do not retry tool calls blindly on error — read the error message and diagnose before retrying - Do not expect the MCP and REST responses to share field names — MCP uses `id` (no prefix, stripped), REST uses `_id`. MCP always adds `url` and `#`-prefixed paycodes; REST does not. --- ## 9. Web App Navigation The Dash web app is at `https://go-dash.app`. Use these URLs to link users to relevant pages: | Route | Description | |---|---| | `/search` | Search and browse listings | | `/listing/{id}` | Single listing detail (accepts MongoDB ID or paycode) | | `/collection/{id}` | Collection detail (accepts MongoDB ID or paycode) | | `/contract/{id}` | Contract detail (MongoDB ID) | | `/sellers/{handle}` | Public seller profile page — shows bio, location, and active listings for that handle. Clicking a seller handle anywhere in the app navigates here. | | `/add-listing` | Buyer listing creation form (mentions the Matching Engine, which runs in the background to connect listings with the right buyer) | | `/contracts/new` | New contract creation form | | `/profile` | Authenticated user's profile | ### Profile tabs The `/profile` page has the following tabs: - **Items** — the user's items, with sub-filter chips: Collections and Listings. Defaults to Collections. The former Purchased and Sold chips are gone; both re-listed transactions as cards and drifted from the payment record (a refunded sale still read as sold). Both directions now live in Account > Transactions. - **Likes** — unified list of liked listings and liked collections, interleaved newest-first. Filter chips at the top let the user view All / Listings / Collections. There is no MCP tool equivalent for the liked collections portion of this view. - **Contracts** — contracts where the user is creator or counterparty - **Account** — everything about the account, in three sub-tabs: - **Details** — Personal or Seller Information, Billing Details (payment methods and the billing address together), Delivery Details, Privacy, API Keys, and Contract Usage (this month's contract executions and cost; formerly its own Usage sub-tab). - **Transactions** — Seller Extension (connecting Stripe), then Account Overview, then one list of everything sold and bought. Account Overview reads differently by role: a seller sees total earnings, this month, total sales, active listings and average sale; a buyer sees purchases, liked and requests. Each list row carries the item, paycode or branch handle, counterparty handle, amount in the transaction's own currency, and the Dash fee on sales. Filter chips All / Sold / Bought, a search box, and CSV export. Opening a row shows Stripe's payment detail for that one payment, where refunds and disputes are handled. This is the same set of facts the `purchase` and `seller` MCP tools return. - **Pricing** — the contract execution pricing bands, same source as `/pricing`. ### Contract timeline display Contract timeline entries are colour-coded by status to match the status badge at the top of the contract page: | Status | Colour | |---|---| | Draft | Grey | | Proposed | Blue | | Under Review | Amber | | Accepted | Green | | Executed | Purple | | Voided / Rejected | Red | --- ## Match Feedback (Step 5) After a `match_recommended` event is written for a listing, the seller can act on it: ### Confirm a match **MCP tool:** `match { action: "confirm", listing_paycode, run_id? }` **REST:** `POST /api/listings/:id/match/confirm` body `{ run_id? }` Seller-only. Sets `match_status` to `"confirmed"` and emits a `next_step_suggested` event recommending you open a branch and `edit` its terms to formalise the deal. ### Reject a match **MCP tool:** `reject_listing_match(listing_id, reason_code, reason?, run_id?)` **REST:** `POST /api/listings/:id/match/reject` body `{ reason_code, reason?, run_id? }` Seller-only. Sets `match_status` to `"no_match"`. Required `reason_code` values: `wrong_product`, `discontinued`, `different_variant`, `low_confidence`, `other`. ### Request a rematch **MCP tool:** `request_rematch(listing_id, hints?)` **REST:** `POST /api/listings/:id/match/rematch` body `{ hints? }` Buyer or seller. Resets `match_status` to `"pending"` and queues a new background match run. Subject to a 5-minute per-listing cooldown — returns 429 if a rematch was already requested within the window. Optional `hints` object: `{ category, keywords, exclude_listing_ids[] }`. ### Feature flag All three endpoints and MCP tools return 503 when `MATCHER_FEEDBACK_ENABLED` is not `"true"`. --- ## Matcher v2 — Quality & Cost (Step 6) The matcher system prompt now includes few-shot exemplars and a Bedrock `cachePoint` so the static content (system text + examples) is cached across runs within a 5-minute window. ### Prompt version Every match event includes a `prompt_version` field (currently `matcher_v2`). This is bumped in `server/lib/prompts/matcher.js` whenever the prompt or exemplars change. ### Cost telemetry Each `match_recommended` and `match_no_candidate` activity event includes: | Field | Description | |-------|-------------| | `input_tokens` | Input tokens charged (excluding cache reads) | | `output_tokens` | Output tokens | | `cache_read_tokens` | Tokens served from the Bedrock prompt cache | | `cache_write_tokens` | Tokens written to cache (first call in window) | | `usd_cost` | Estimated USD cost for this call | Cache reads are ~10x cheaper than fresh input tokens, so the second match run within a 5-minute window should show `cache_read_tokens > 0`. --- ## Matcher v3 — Per-seller memory (Step 7) The matcher now learns from each seller's confirm/reject decisions and applies that history to subsequent match runs for the same seller. ### How it works 1. When a seller confirms or rejects a match (via step 5 tools), a `MatcherMemory` doc is written with compact summaries of both listings and the decision reason. 2. Summaries are generated by a background Haiku call — the doc is written immediately with a basic placeholder, then patched once the LLM returns. 3. On the next match run for the same seller, the memory block is injected into the prompt *after* the cachePoint, so cached content stays cached but each seller gets their own personalised context. 4. Cap: 30 memories per seller (oldest evicted). TTL: 365 days. 5. `low_confidence` rejections are intentionally excluded — too noisy to be useful. ### Prompt version `matcher_v3` — bumped from v2 because the prompt structure now includes a per-seller memory block for sellers who have prior decisions. ### MCP tool `clear_matcher_memory()` — bearer auth (seller only). Deletes all memory docs for the authenticated seller and returns `{ deleted: N }`. Use when the seller's listings have significantly changed and prior decisions are stale. Idempotent. Requires `MATCHER_MEMORY_ENABLED=true`. Returns 503 when the flag is off. For support or to report issues: admin@go-dash.app