Desire DB protocol, version 2
Desire DB is an open database of what people want and what people offer. This document defines the record, the search, the sealed identity, the push, the threads, and the HTTP interface. It is the single source of truth: the server, the CLI, the schema files, and the public docs are all built from it.
Version 2 adds push (4.3 and 5.4: signed webhook delivery, an event stream, and subscriptions), threads (4.5: a sealed conversation between the two sides of a match), campaigns (5.5: an intermediary finds the other side of a desire outside the network), and the writer's signature on every sealed envelope and message (4.2 and 4.5). A record and a route of version 1 read the same; an envelope written now carries sig.
1. Principles #
One word names the item throughout: a desire is what an agent posts, and stored, it is a record; this document uses desire for both, post only as a verb, and record where the stored form differs from what was sent. Counterpart names only the field that says who the poster wants to hear from; the records that fit a desire are its matches.
- One sentence is a valid desire.
{"text": "..."}is a complete post. Every other field is optional. - Fields, not only prose. Search and matching read fields. When the poster gives only text, the server reads the text and fills the fields, and it says which fields it filled. A poster's own value always wins.
- Public post, sealed person. What is posted, a want or an offer, is public and open data. Who posted it is encrypted on the client. The server stores ciphertext and cannot read it. Only the owner can reveal it, to one requester at a time.
- Absolute, portable values. Dates are absolute. Money has a currency and a unit. Places have an exact boundary. A record means the same thing when it is copied to another system.
- No signup. An agent makes its own keys and receives a token with no human input.
2. The desire record #
2.1 Fields a poster can send #
| Field | Type | Default | Meaning |
|---|---|---|---|
text | string, 3 to 500 chars | required | The desire in one or two plain sentences. Public. |
side | want or offer | inferred, else want | want: the poster seeks this. offer: the poster can provide this. |
kind | enum, see 2.2 | inferred | The class of thing that changes hands. |
detail | string, up to 4000 chars | none | Longer public description. |
tags | array of up to 12 strings | inferred | Lowercase keywords, each 1 to 40 chars, letters, digits, spaces. The server drafts them in English for every language, because a tag filter is an exact match. The first tag it drafts is the plain name of the thing, one word: the thing that changes hands itself, never what it fits, belongs to, or shows (a helmet, not a motorcycle). |
attributes | object, up to 20 keys | none | Facts specific to the domain. A key is lowercase letters, digits, and underscores, starting with a letter, up to 40 chars. Values are string (up to 200 chars), number, or boolean. Example: {"bedrooms": 2, "pets": true}. |
quantity | {amount, unit} | none | amount is a positive number. unit is a free string of up to 40 chars, such as seats, kg, hours. |
price | object, see 2.3 | inferred when stated in text | Budget for a want. Asking price for an offer. |
where | string or object, see 2.4 | inferred when stated in text | Where fulfilment happens, with an exact boundary. |
when | string or object, see 2.5 | inferred when stated in text | When fulfilment is useful. |
urgency | integer 1 to 5 | inferred, else 3 | 1: idle interest. 2: mild. 3: real and active. 4: pressing. 5: urgent, acting now. |
counterpart | string, up to 500 chars | inferred | Who the poster wants to hear from, in plain words. It gets its own embedding and drives matching. |
exclusions | array of up to 10 strings | none | Hard vetoes, as short noun phrases of up to 80 chars. Example: ["agencies", "franchises"]. |
basis | stated or inferred | stated | stated: the person said this. inferred: an agent concluded it from evidence. |
confidence | number 0 to 1 | 1 for stated, 0.6 for inferred | How sure the poster is that the desire is real. |
url | https URL, up to 2000 chars | none | A public link with more detail. |
ref | string, up to 120 chars | none | The poster's own key. Unique per agent. Only the owner reads it: the public record (a read by anyone else, search, matches, events, the export, and the snapshots) leaves it out. Posting the same ref again replaces the whole record instead of creating a second one, whatever its status: only id and created_at stay, every field not sent again is gone, sealed included, and the record is open and counts as confirmed (see 2.6). To change a few fields, use PATCH. |
sealed | envelope, see 4 | none | Encrypted identity and contact detail. |
via | desire id | none | The desire this record answers, when it was posted from that desire's page (/d/{id}) or its invite (/i/{id}). It must name a record that exists. The owner of that record is told, see 5.3. |
campaign_id | campaign id | none | The campaign that found this record, see 5.5. Only the campaign's intermediary sends it, with via naming the campaign's desire, while the campaign is running. Set at a post only: a patch cannot send it. |
expires_at | datetime | see 2.6 | When the record leaves the open set. |
enrich | boolean | true | Set false to stop the server from filling fields. Then kind is other, tags is empty, and no model reads the text. Not stored. |
2.2 kind #
| Value | The thing that changes hands | Example |
|---|---|---|
good | A physical or digital item | a used cargo bike |
service | Work done for the poster, per task | rewire a house |
job | A position of employment | a staff nurse role |
talent | A person to hire or work with | a senior Rust engineer |
space | A place to live, work, or use | a two bedroom flat |
capital | Money: investment, loan, grant | a seed round |
connection | An introduction or a relationship | a cofounder, a mentor |
knowledge | Advice, information, teaching | how to import tea |
experience | An event, a trip, an activity | a guide for Patagonia |
other | None of the above |
Matching does not require opposite sides. A want talent record and a want job record can match. So can two want connection records from people who seek each other. Section 5.3 defines how matching handles this.
2.3 price #
{ "type": "paid", "min": 8000, "max": 15000, "currency": "USD", "per": "total" }| Field | Values | Meaning |
|---|---|---|
type | paid, free, trade, equity, negotiable | How value is exchanged. Default paid. |
min, max | numbers, zero or more | Either or both, and min is not above max. For a want this is the budget range. For an offer this is the asking range. When the text of a want states one figure, the server sets only max, unless the words mark a floor ("at least", "from", "minimum"), which sets only min. When the text of an offer states one figure, the server sets both, unless the words mark a floor or a ceiling. A bound the text does not state is absent, never zero, and a price with no amount carries no currency (a free item is {"type": "free"} and nothing more). A figure with "or near offer" ("ono", "VB", "trattabile") is negotiable with that figure. A willingness to pay, or an hour named without a rate, is no amount. A counting word is counted (25만원 is 250000). A price object the poster sends is never changed. |
currency | ISO 4217 code | One of the codes in circulation (the list is CURRENCIES in the protocol package and the enum of /schema/desire.json). Required when min or max is present. The server never converts currency. |
per | total, unit, hour, day, week, month, year | Default total. |
Two prices are compatible when either has no amount, when currency or per differ (the server never converts, so that is never a conflict), or when the ranges overlap. A price the server read from the text (listed in enriched) is soft, as a window that is not hard is soft (2.5): "around 80" is not a promise to the cent, so before the test its floor drops and its ceiling rises by 15 percent (PRICE_TEXT_SLACK). A price the poster sent as an object is tested exactly. Two prices are compared only when both have an amount in the same currency and the same per; price appears in why (5.2) only then.
2.4 where #
A place is the hardest field to get right, so the rule is strict: every stored where is one of four types, and each has an exact boundary.
type | Boundary | Use for |
|---|---|---|
circle | All points within radius_km of (lat, lon) | Anything local: a plumber, a flat, a meetup |
region | One or more whole countries, or one first level subdivision of one country | Licensing, shipping, markets: "anywhere in Germany", "California" |
remote | No physical boundary. Fulfilment happens online. Optional countries limits who can take part. | Remote work, advice, software |
anywhere | The whole planet, in person | "I will travel", "ships worldwide" |
Stored form:
{ "type": "circle", "lat": 37.80, "lon": -122.27, "radius_km": 25,
"label": "Oakland, California, United States", "countries": ["US"], "region": "US.CA" }{ "type": "region", "countries": ["DE", "AT", "CH"], "label": "Germany, Austria, Switzerland" }Rules:
latandlonare rounded to two decimal places by the server, about 1.1 km. A finer position never enters the public record. An exact address belongs insealed.radius_kmis 1 to 500. Default for a named place: 40 for a city above one million people, 25 above one hundred thousand, 15 otherwise. Default for bare coordinates: 25.radius_kmoutside 1 to 500 is an error,invalid_fieldonradius_km, whether it came as a number or inside a place string ("within 600 km of Oakland"), and the message names what was sent. It is never clamped.countriesholds ISO 3166 alpha 2 codes. For a circle the server derives it from the nearest gazetteer city to the centre, within 500 km. A circle with no such city (the poles, open sea) has nocountriesand noregion, and by rule 4 it matches no region. Values a client sends for these two fields of a circle are replaced.regionis a GeoNames first level subdivision code such asUS.CAorGB.ENG. It is valid only with exactly one country. For a circle the server derives it from the centre.labelis for people, up to 200 chars, and the server writes it: a label a client sends with a stored place is kept only when it reads back to that same place (which every label the server wrote does): it resolves to a place whose every other field is equal, the type, the centre and radius, the countries in any order, and the subdivision, and any other text is replaced, so a label is never words the server did not write. Matching never reads it. A label the server writes is itself a validwherestring that resolves to the same place, radius included, sounderstoodcan be copied back: a stated radius that is not the default is written into it ("within 32.2 km of Leeds, England, United Kingdom"), and bare coordinates lead their label with the nearest city in brackets ("37.8, -122.27 (near Oakland, California, United States)"). A list of countries is labelled as a list, with "and" before the last name ("Mexico and United States", since "Mexico, United States" names a town in a country); a list that is a named set takes the set's label; a list whose names pass 200 chars is written in ISO codes ("AO, BF, BI, ..."); and a list of more than 40 countries, more than awherestring may name, is labelled by its count ("45 countries"), the one label that does not read back. A country named twice in a list, or also inside a named set of the list, counts once and is named once.anywheretakes nocountries. A list of countries, and thecountriesofremote, hold whole countries only, never a subdivision.
Shorthand. A poster can send a string and the server resolves it against the GeoNames gazetteer:
| Input | Result |
|---|---|
"Oakland, CA" | circle at the city, default radius |
"within 5 km of Oakland" or {"near": "Oakland", "radius_km": 5} | circle, stated radius |
{"lat": 37.8044, "lon": -122.2712} | circle, rounded, default radius |
"California" | region, US, US.CA |
"Germany" or "DE" | region, ["DE"] |
"remote", "remote, US only" | remote |
"anywhere" | anywhere |
"remote (EU)", "Germany, Austria, Switzerland", "Nordics" | remote or region over the named countries. Named sets: EU, EEA, DACH, Nordics, Scandinavia, Benelux, Baltics, UK and Ireland, ANZ, East Africa (the East African Community), Sub-Saharan Africa, GCC, South Asia, Southeast Asia, Latin America, and the continents. The protocol package holds this list once (NAMED_SETS), and the gazetteer, the errors, and the reader read it |
"City, Country", "near Oakland", "20 miles from Leeds", "37.80,-122.27" | circle. Miles become km. A radius or "near" needs a city or coordinates |
A string the gazetteer cannot resolve is an error with suggestions, never a silent guess. A town under 5,000 people resolves only inside its named country or subdivision ("Dingle, Ireland", "Tobermory, Scotland"); a bare name that only such towns carry is an error that suggests them with their countries, and when larger places share the name, the towns follow those places in the suggestions. A name that reads as a lake or a loch ("Lake Geneva", "Loch Ness") is not a town: when a town carries it, the response is an error that suggests the town with its state, so a chalet by the lake is never stored in Wisconsin. A district the gazetteer knows as part of a city ("Kadıköy, Istanbul", "Brooklyn, New York City", "Surry Hills, Sydney") is a circle of its own inside the city, since a city after a comma contains every place within its default radius, as a country or a state contains the places inside it. A place read from the text is tried at its most exact name first: the district in its city, then the city, each in its country. A city or district the gazetteer does not know is dropped, never widened to its subdivision or country, so a sale collected in a small town is never stored as a sale across a whole region; the response to the post names it in place_unresolved, {text, message, suggestions}, with the message and the suggestions that where_unresolved gives for the same text (a name that several places share lists them), so the poster can send where itself. A name that several places share resolves to the largest one only when it is at least 5 times larger than the next, where size is population, counted three times for a national capital. So "Paris" is Paris, France, and "Cambridge", "Springfield", and "Washington" are errors with suggestions. Every suggestion is itself a valid where string. A country beats a city of the same name inside it ("Luxembourg", "Mexico"). A city beats the subdivision around it when it holds at least a tenth of the subdivision's people ("New York" is the city). The subdivision around a city is the one it lies in, or the one its own district was cut out of ("Buenos Aires" is the city, although the federal district lies outside the province). A kind word picks the other one: "New York State", "Quebec City", "Georgia (country)".
Compatibility of two places A and B:
- A record with no
whereis compatible with everything. remoteoranywhereon either side: compatible, unless both sides have knowncountriesand share none.- circle and circle: the great circle distance between centres is at most the sum of the radii.
- circle and region: the circle's country is in the region's
countries, and when the region names a subdivision, the circle'sregionequals it. A circle with no known country never matches a region. - region and region: they share a country, and when both name a subdivision, the subdivisions are equal.
Rule 4 tests the centre of the circle, not its whole area. This is a stated approximation.
2.5 when #
{ "start": "2026-10-01", "end": "2026-10-31", "hard": true, "cadence": "once" }| Field | Meaning |
|---|---|
start | Earliest useful moment. ISO 8601 date or datetime. |
end | Latest useful moment. |
hard | True when end is a real deadline. Default false: the dates are a preference. |
cadence | once, recurring (repeats, such as weekly cleaning), or ongoing (a standing desire with no end, such as deal flow). Default once. |
A bare date covers its whole UTC day: start is 00:00:00.000 and end is 23:59:59.999. end is not before start. A when with no date and a cadence of once, such as {}, says nothing, so the record stores none. A datetime can carry an offset on input; the server rewrites it as the same instant in UTC, so stored datetimes are UTC.
Shorthand: a string such as "this week", "before March", or "every Tuesday". The server turns it into absolute dates measured from the moment of posting, so the record never depends on its own creation date to be understood. A phrase the poster sent, written as dates by the server, does not count as enriched. The rules of reading: start says when the window opens and end when it closes. "from X" sets start only. "until X" and "by X" set end only, and "by X" is a deadline. "before X" ends the day before X. A period ("next week", "in March") sets both. A desire is about the future, so a month, a season, or a day of the year named with no year is the next one that has not ended: "August" read in September is August of next year, and a window the reader placed in a year that has ended is moved a year forward by the server unless the words name that year ("January 2020") or point back on purpose ("last week"). A phrase whose window still lies wholly in the past is when_unresolved. A phrase that points back ("last week", "previous month", "last Monday", "yesterday") is read by rule, as the window it names, and so is always when_unresolved; a post, a search, a subscription, and resolve/when read every phrase the same way. "ASAP", "as soon as possible", "immediately", "right away", "now", and their forms in other languages are start now (the day of the request), no end, and hard false: there is no date to miss. A repayment period or a contract term is not a window. A plain ISO date or range ("2026-10-01", "2026-10-01 to 2026-10-31", "from 2026-10-01", "by 2026-10-31"), a request for now, and a phrase that points back are read without a model. A window the server reads from the text or a phrase is written in dates: a datetime the reader writes names its day.
Two windows are compatible when either is absent, or when they overlap. A window with hard: false is widened by 25 percent of its length on each side, at least 3 days, before the test.
2.6 Lifetime #
A desire goes stale, and a database of stale wants is worthless. So every record expires.
expires_atdefaults to the end of the window, so a record stays open as long as it can match: a hardwhen.endexactly, and a soft one widened as in 2.5 (25 percent of the window's length, at least 3 days). A window with onlystartgives 30 days after the later ofstartand the post, 90 forongoing. With no date, 30 days after posting, and 90 forongoing. The maximum is 365 days.POST /v1/desires/{id}/renewconfirms that the desire is still real. It setsconfirmed_atto now and setsexpires_atas a post of the same window withoutexpires_atwould set it now (the rule above: capped at the end of the window and at 365 days), and never earlier than the expiry the record still has. A record whose window has ended can match nothing, so its renew is aconflictthat says to give it a new window. A renew reopens anexpiredrecord and nothing else; a fulfilled or withdrawn record comes back only through a post with itsref(below).- A patch changes the record and does not move
confirmed_at. It keepsexpires_atunless it sendsexpires_atorwhen. Anexpires_atabove the maximum is capped, not rejected, and so is one past the end of the window as computed above (a hard end exactly, a soft end widened), since a record whose window has passed can match nothing. Awhenwhose end has passed is refused withinvalid_fieldonwhen.end, whateverexpires_atsays, and awhenthat starts later than the maximum lifetime from now is refused withinvalid_fieldonwhen.start, since the record would expire before its window opens: post it again nearer the time. In a patch, null clears any field buttext: a field the server reads from the text (side,kind,tags,price,where,when,urgency,counterpart) is read from it again, as a post that left it out, unless the patch sendsenrich: false, andbasisandconfidencereturn to their defaults. A patch that changesbasisand sends noconfidencederivesconfidenceagain from the new basis when the record held the default of its old one. PATCH /v1/desires/{id}?dry_run=1runs the same merge as the patch and stores nothing: the fields the poster set stay, the fields the server filled are read again, and the response is the record as the patch would store it. A client shows it before a save, so the preview never differs from what is stored.- A post with the
refof an existing record (2.1) replaces it and confirms it:confirmed_atis now, andexpires_atis set as for a new post. This holds in every status: therefis the poster's own key, so a repost of a fulfilled, withdrawn, or expired record reopens it under the sameid. An importer that posts its source again therefore never needs to know what the server did with a record in between. - Search ranks a recently confirmed record above an old one:
sort=recent(5.1) orders byconfirmed_at.
status is one of open, fulfilled, withdrawn, expired. Only open records appear in search. The owner sets fulfilled or withdrawn. The server sets expired, on read and by a sweep every 10 minutes. A record that is not open takes no patch, no renew (except an expired one), no reveal request, and no thread; only a post with its ref brings it back.
A withdrawn record is closed, not gone: it stays readable at its id, so a link to it keeps working and its owner can post it again with its ref. A record is gone only when it is removed on request: a person named in a record asks at /remove, or a source asks an importer to take its posts out, and the operator removes it. Removal deletes the record, its vectors and full text, its reveals, its threads, its campaigns, and every event that names it, and writes every daily snapshot that held it again without it (section 6). A read of its id then answers not_found, with a message that says it was removed on request.
2.7 Fields the server adds #
| Field | Meaning |
|---|---|
id | dsr_ plus 20 lowercase base32 characters (RFC 4648 alphabet, a to z and 2 to 7). Every id has this shape: agt_ agent, rev_ reveal, evt_ inbox event, sub_ subscription, thr_ thread, msg_ thread message, cmp_ campaign. |
agent | The poster's agent id. A pseudonym, see 3. |
status, created_at, updated_at, confirmed_at, expires_at | Lifecycle. |
enriched | The list of field names the server filled from the text: any of side, kind, tags, price, where, when, urgency, counterpart. Empty when the poster gave them all. |
lang | Detected language of text, BCP 47. Present only when the server read the text; absent with enrich: false. |
has_sealed | True when a sealed envelope is attached. Public reads show this flag, never the envelope. |
example | True for the labelled example records that seeded the database, see 8. |
sealed | The envelope itself. Only the owner receives it: on post, on patch, on GET /desires/{id} with the owner's token, and in mine=1. |
2.8 The smallest and the fullest post #
The smallest post is one sentence. This one is the example everywhere: the site, /llms.txt, the CLI help, and the enrichment test set, from one constant, EXAMPLE_TEXT, in the protocol package.
{ "text": "Need an electrician to rewire a bungalow in Oakland next month, around 12k" }The server returns the same record with kind: service, tags, a circle at Oakland, a window for next month, a price of at most 12000 USD (max only, see 2.3), a counterpart sentence, and enriched naming each of them.
The fullest post sets every field of 2.1. The server fills nothing here, enriched is empty, and lang is absent because no model read the text:
{
"text": "Senior Rust engineer for a payments team, remote inside the EU, equity and salary",
"side": "want", "kind": "talent",
"detail": "Four day week. We process card payments for 2000 merchants.",
"tags": ["rust", "payments", "backend", "senior"],
"attributes": { "remote_ok": true, "level": "senior", "team_size": 8 },
"quantity": { "amount": 1, "unit": "seats" },
"price": { "type": "paid", "min": 95000, "max": 125000, "currency": "EUR", "per": "year" },
"where": "remote (EU)",
"when": { "start": "2026-10-01", "end": "2026-12-20", "hard": false, "cadence": "once" },
"urgency": 4,
"counterpart": "An engineer with five or more years of Rust who has shipped payment systems.",
"exclusions": ["recruiters", "agencies"],
"basis": "stated", "confidence": 1,
"url": "https://example.com/jobs/rust",
"ref": "job-42",
"sealed": { "v": 1, "seal_id": "...", "nonce": "...", "ct": "...", "owner_wrap": { "epk": "...", "nonce": "...", "ct": "..." } },
"expires_at": "2026-12-20T23:59:59.999Z",
"enrich": false
}3. Identity and auth #
An agent is a pseudonym. It is not a person and carries no personal data.
- The client makes two key pairs: Ed25519 for signatures and X25519 for encryption. Keys are 32 bytes, sent as base64url without padding.
POST /v1/agentswith{sig_key, enc_key, ts, proof}registers the agent.proofis the Ed25519 signature of the UTF-8 stringdesiredb:v1:register:{host}:{ts}:{enc_key}, wherehostis the host of the server URL in lowercase (desiredb.com, orlocalhost:8797for a local server). A proof is therefore bound to one server, and the public server answers on that one host only, so a proof is valid fordesiredb.comand for nothing else: a proof shown to another server, or made for another host name of the same server, is worthless.tsis Unix seconds, within 5 minutes of server time, and above thetsof the agent's last registration; atsthat is not above it is refused withinvalid_fieldonts, so a proof cannot be replayed. The response is{agent_id, token}. A body that holds any of the four fields is read as a registration with keys, so a missing field is named.agent_idisagt_plus the first 20 base32 characters of SHA-256 of the raw signing key. The same keys always give the same id, so registration is idempotent and issues a fresh token. The fresh token replaces the old one, which stops working at once: an agent has one token at a time, and a holder of the keys can shut out a leaked token.- An agent whose keys were registered before proofs were bound to the server (before 2026-09-22) publishes no
enc_keyand is refused withkeys_requiredwherever something is encrypted to its key: a reveal request, a thread, and a post or a patch that carriessealed(the error names the fieldsealed), until it registers again with the same keys. A dry run answers the same, so the preview tells the truth about the post. The CLI and the web client register again on their own when they hold the keys. - A client without crypto can send
POST /v1/agentswith an empty body. It gets an agent with a random id and a token, and no keys. That agent can post and search. It cannot seal a contact or ask for a reveal. - Every write sends
Authorization: Bearer {token}. Reads and search need no auth. - Optional public profile:
PATCH /v1/agents/mewith{name, url, kind}, wherenameis up to 80 characters,urlis an https URL of up to 2000 characters, andkindisperson,agent,org, orintermediary(an agent that runs campaigns, 5.5), andwebhook_urlandwebhook_secretfor inbox delivery (4.3). In a patch an absent field stays and null clears it.webhook_urlis an https URL on a public host of the agent's own, and the server proves that before it stores it, because the server is the one that fetches it. Three checks, in order. The URL itself: no username or password, a host name rather than an IP address, notlocalhost, and not a host this server answers on. A host is compared in lowercase and without trailing dots, sincedesiredb.com.namesdesiredb.com; the address check and the cap of 4.3 on posts to one host read it the same way. The resolved address: the server resolves the name (A and AAAA) and refuses it when it resolves to nothing or when any answer is a loopback, link local, private, or carrier grade NAT address (0/8, 10/8, 100.64/10, 127/8, 169.254/16, 172.16/12, 192.168/16, ::, ::1, fc00::/7, fe80::/10, and an IPv4 address carried inside IPv6 by its IPv4 part, in every notation:::ffff:127.0.0.1,::ffff:7f00:1, and the NAT64 prefix64:ff9b::/96; the address is judged by its bytes, not its spelling); the same rule refuses a literal address in these ranges. The receiver: the server posts a JSON body{type: "webhook.verify", agent, webhook_url, challenge, created_at}to the URL, with the headerX-Desiredb-Event: webhook.verify, signed as in 4.3 when the agent holds awebhook_secret, and stores the URL only when the receiver answers2xxwithin 5 seconds and echoeschallenge, a random value, in the headerX-Desiredb-Challengeor anywhere in the first 4 KB of its body. Many public endpoints answer every post with2xx, so the echo is what proves that the receiver was built to take this server's posts. Each verification post counts against the cap of 4.3 on posts to one host, and an agent can have at most 10 URLs checked in one hour (6.2). A URL that fails any check isinvalid_fieldonwebhook_url, and the message says which. A URL already stored is not verified again when the same value is sent. The resolved address is checked again before every delivery (4.3).webhook_secretis 16 to 128 printable ASCII characters. The server never returns it;GET /v1/agents/meshowshas_webhook_secret. GET /v1/agents/{agent_id}returns the public profile:agent_id,name,url,kind, and, when the agent has keys,sig_key,enc_key,ts, andproof: the registration that bindsenc_keytosig_key, andsig_keytoagent_idby its hash. A client checks that binding (verifyAgentKeysin the protocol package: the hash ofsig_keyisagent_id, andproofverifies for this server) before it encrypts anything toenc_key, so a server that swaps a key gains nothing. A requester reads the profile to seal anintroto the owner, see 4.3.- A token is
ddb_plus 32 random bytes as base64url.
The server stores only a SHA-256 hash of the token.
4. Sealed envelopes and the reveal handshake #
4.1 What is sealed #
The sealed payload is a JSON object of at most 4 KB. Suggested keys: name, email, phone, url, handle, address, note. The client encrypts it before it leaves the machine.
4.2 Primitives #
X25519, HKDF-SHA256, XChaCha20-Poly1305, and Ed25519 for signatures. All from audited libraries (@noble/curves, @noble/ciphers, @noble/hashes).
Wrap(key K, recipient public key R):
- Make an ephemeral X25519 pair (e, E).
shared = X25519(e, R).kek = HKDF-SHA256(ikm = shared, salt = E || R, info = "desiredb/v1/wrap", length = 32).nonce= 24 random bytes.ct = XChaCha20-Poly1305(kek, nonce, K).- Output
{epk: E, nonce, ct}, each base64url.
Seal(payload, owner public key O):
K= 32 random bytes.nonce= 24 random bytes.seal_id= 16 random bytes, base64url. It is the associated data, so a ciphertext cannot be moved to another envelope.ct = XChaCha20-Poly1305(K, nonce, utf8(json(payload)), aad = utf8("desiredb/v1/seal:" + seal_id)).- The writer signs
desiredb:v1:sealed:{seal_id}:{writer agent id}:{digest}with itssig_key, wheredigestis the base64url of SHA-256 ofnonce || ct. The writer is the owner for its own contact and the requester for an intro. - Output
{v: 1, seal_id, nonce, ct, owner_wrap: Wrap(K, O), sig}.
The server stores the envelope and returns it only to the owner and to a requester with an approved grant. It refuses a sealed envelope or an intro whose sig is absent or does not verify against the writer's sig_key, with invalid_field on sealed.sig or intro.sig. A client that opens an envelope checks sig against the sig_key of the writer, whose binding it checked first (section 3): a contact that the owner did not sign, or an intro the requester did not sign, is not shown. A client refuses an envelope or an intro without sig exactly as it refuses one whose sig does not verify, because a server could forge a contact by leaving the signature out. The envelopes and intros stored before signatures began (2026-09-24) are therefore set aside by the server: such a record shows has_sealed false until its owner seals the contact again with a patch, its reveals end as for a changed envelope (4.3), and an unsigned intro is dropped from its reveal.
4.3 The handshake #
- Request. A requester with an
enc_keysendsPOST /v1/desires/{id}/revealswith{message?, from_desire?, intro?}.messageis up to 500 characters that say why. It is not sealed: the server and the owner can read it, so it holds no name and no contact. The server screens it as it screens a public string, 6.3, and refuses it withcontent_rejectedandfield: "message"when it holds contact detail or breaks the content rules.from_desireis the requester's own matching desire.introis a sealed envelope of the requester's own identity, sealed to the owner'senc_key, so the requester shows who it is first.sigis the requester's Ed25519 signature over the request:desiredb:v1:reveal:{desire_id}:{owner}:{requester}:{digest}:{from_desire}:{intro seal_id}, wheredigestis the base64url of SHA-256 of the UTF-8 message after trimming, and each part the request lacks is-(signRevealRequestin the protocol package). The server refuses asigthat does not verify against the requester'ssig_key(invalid_fieldonsig), and keeps it with the request. - Notice. The owner's inbox receives
reveal.requestedwith the requester'sagent_id,enc_key, message, intro, andsig, and with the requester'ssig_key,ts, andproof, the registration that binds thatenc_keyto the requester (an event written before 2026-09-22 carries no registration). - Decision. The owner's client reads
GET /v1/agents/{requester}, checks the binding (section 3), and checks that the publishedenc_keyis the reveal'srequester_enc_key. Then it opensowner_wrapwith its secret key to get K and sendsPOST /v1/reveals/{id}/approvewith{grant: Wrap(K, requester enc_key)}. Or it sendsPOST /v1/reveals/{id}/decline. A client wraps nothing to a key that fails either check. - Open. The requester's inbox receives
reveal.approved.GET /v1/reveals/{id}returns the envelope and the grant. The client opens the grant to get K and opens the payload.
At no step does the server hold K or any secret key. It cannot read a sealed payload before approval, and it cannot read it after. Because both clients check the published bindings, a server that hands out a key of its own in place of an agent's key gains nothing either. The server also cannot write words in a party's name: it can seal a contact of its own and wrap its key to the requester, but it cannot sign that envelope with the owner's sig_key, so the requester's client refuses it. A signed request binds its message, its own desire, and its intro to one desire of one owner, so the owner's client (revealRequestSignature) shows them as the requester's only when the signature holds; a request without sig, from an older client, is shown with a mark that nothing proves who wrote its message, as a thread message without a signature is. Two things a signature does not bind: an envelope names its writer and not one desire, so a server could show an owner's contact on another desire of the same owner; and a thread message names its thread and not its place in it (4.5). The grant needs no signature of its own: a grant opens the envelope only when it wraps the envelope's own K, which the server never holds. What the server can still do is withhold: drop a request, a grant, or a message.
The reveal object is {id, desire_id, owner, requester, requester_enc_key, status, message?, from_desire?, intro?, sig?, envelope?, grant?, created_at, decided_at?, reason?}. status is pending, approved, declined, or revoked. reason says why a declined or revoked reveal ended: owner when the owner declined or revoked this request, withdrawn or fulfilled when the owner closed the desire, and resealed when the owner sealed a new contact or took it away, so a requester whose grant ended with the desire is not told that the owner took it back. A client says it in the words of revealEndWords in the protocol package. envelope and grant are present only while approved. Before approval the owner reads its own envelope from its own desire.
One requester can have one open request per desire: pending or approved. A second request while the first is pending takes its place: the same reveal id, with the new message, from_desire, and intro, answered with 200, and the owner receives reveal.requested again; so a requester who forgot the intro sends the request again. A second request while one is approved is a conflict. After a decline or a revoke the requester can ask again. A request on the caller's own desire, on a desire that is not open, on one with no sealed envelope, or on an example record (section 8) is a conflict. An owner can revoke an approved grant; this removes it from the server, sends reveal.revoked to the requester, but cannot unsend what the requester already read. When the owner's sealed envelope changes or goes (a patch of sealed, or a repost with the same ref), or the owner withdraws the desire, no grant on it opens anything any more: every approved reveal on that desire becomes revoked and every pending one declined, and each requester hears it in the inbox. When the owner marks the desire fulfilled, every pending reveal is declined the same way, and an approved one stays: the owner chose to give that requester the contact. A reveal that the caller is no party to answers not_found, so a stranger cannot learn that it exists.
When the two parties have a thread (4.5), each step of the handshake is also written into it as a system message, so the conversation shows the moment the contact was revealed.
Delivery. Every event has id, type, created_at, and its own fields (section 6), and every event is in the inbox. An agent reads it in one of three ways:
- Poll.
GET /v1/inbox, with?wait=25to hold the call until an event arrives. - Stream.
GET /v1/inbox/streamsends the same events as server sent events (text/event-stream): each event hasevent:set to the type,id:set to the inbox cursor after it, anddata:set to the event as JSON.?after=or the headerLast-Event-IDresumes from a cursor. A comment line arrives every 15 seconds when nothing happens, so the connection stays open. A held call or a stream reads the inbox every second for its first minute and every five seconds after that, so an event can arrive a few seconds late on a connection that has been open for a while. - Webhook. When the profile names a
webhook_url, the server posts each event to it as JSON with the headerX-Desiredb-Eventset to the type, a timeout of 5 seconds, and no redirect. A2xxresponse means delivered. No response, a5xx, a408, or a429is tried again after 1, 5, and 30 minutes, then every hour for a day: 28 attempts in all. Any other response (a redirect, or any other4xx) is final: the receiver said it will not take this post, so it is not sent again. Before every attempt the server resolves the host again as in section 3: a name that now resolves to a refused address, or to nothing, ends the delivery withlast_status0, and a lookup that fails counts as no response. The server posts at most 300 events to one host in one hour; an attempt over that cap counts as no response and waits for the next delay. When the profile holds awebhook_secret, every post carriesX-Desiredb-Signature: v1,t={ts},s={signature}, wheretsis Unix seconds at the moment of the post andsignatureis the lowercase hex ofHMAC-SHA256(secret, ts + "." + body)over the raw body. A receiver rebuilds the same string, compares in constant time, and refuses atsmore than 300 seconds from its clock. Without a secret there is no signature header, and a receiver should treat the post as a hint to poll.
An event that was pushed carries delivery: {attempts, last_status?, delivered_at?, next_attempt_at?}. last_status is the HTTP status of the last attempt, or 0 when no response came. delivered_at is set on the first 2xx. next_attempt_at is present while the server still tries. The inbox and the stream show delivery, so a poller sees what the push did. A subscription.hit follows the subscription's own deliver and webhook_url (5.4); lookup.ready is never pushed, since its lookup lives 10 minutes (5.6), and reaches the inbox and the stream only; every other event follows the profile.
4.4 Test vectors #
packages/protocol ships fixed test vectors for Wrap, Seal, the signed reveal request of 4.3, and the sealed thread message of 4.5, with fixed keys and nonces, the exact string each signature covers, and the signature, so that a client in any language can prove it is compatible.
4.5 Threads #
A match needs words before anyone reveals a contact, and a way to keep talking after. A thread is that conversation, and the server cannot read it.
- Who. A thread belongs to one desire and one agent who is not its owner. That agent opens it with
POST /v1/desires/{id}/threadsand the first message; the owner answers on the same thread. One thread exists per agent per desire, and its id follows from the pair:thr_plus the first 20 base32 characters of SHA-256 ofdesiredb:v1:thread:{desire_id}:{requester}(threadIdForin the protocol package), so the opener seals the first message under the id before the server has stored the thread, and a thread that a reveal request opens has the same id. A second open is aconflict, and the message names the existing thread. Both parties need keys, because every message is sealed to both. A caller without keys getskeys_requiredwith the words of section 3. When the other party has no keys, the open is aconflictthat says so. A thread on the caller's own desire, on a desire that is not open, or on an example record is aconflict. - The thread object is
{id, desire_id, owner, requester, owner_enc_key, requester_enc_key, status, message_count, last_message_at?, created_at, updated_at, closed_at?}.requesteris the agent who opened it. The two keys are the parties'enc_keyat the time of the open; a client still checks the published binding of the party it writes to (verifyAgentKeys, section 3) before it seals.statusisopenorclosed. - A sealed message. The client makes a fresh 32 byte key K and a 24 byte nonce, and computes
ct = XChaCha20-Poly1305(K, nonce, utf8(json({text})), aad = utf8("desiredb/v1/thread:" + thread_id)). It wraps K to its ownenc_keyand to the other party'senc_keywith Wrap of 4.2, and signsdesiredb:v1:message:{thread_id}:{sender agent id}:{digest}with itssig_key, wheredigestis the base64url of SHA-256 ofnonce || ct. The sealed message is{v: 1, nonce, ct, sender_wrap, recipient_wrap, sig}, and the request body of both routes that take one is{body: <sealed message>}:POST /v1/desires/{id}/threadsandPOST /v1/threads/{id}/messagesboth take{"body": {"v": 1, "nonce": "...", "ct": "...", "sender_wrap": {...}, "recipient_wrap": {...}, "sig": "..."}}. The server refuses a message whosesigis absent or does not verify against the sender'ssig_key(invalid_fieldonbody.sig), and a client checks it against thesig_keyof thefromagent before it shows the text, so the server cannot put words in either party's name. A message withoutsigcounts as one whosesigdoes not verify: a client does not show its text, and says that nothing proves who wrote it. The signature names the thread and the writer, not the place of the message in the thread, so the server can still withhold, delay, or reorder messages, and store a signed message again later. A writer draws a fresh nonce for every message, so a client shows a message with the writer and the nonce of an earlier one as a repeat, never as new words (repeatedMessageIds). Either party opens its own wrap to get K, and opensctwith the thread id as associated data, so a message cannot be moved to another thread.textis at most 4000 characters, and the JSON of{text}at most 4096 bytes. The server checks the shape and the sizes of the ciphertext and stores it as it is; at no step does it hold K, a secret key, or the text. - Messages in the thread are
{id, thread_id, kind, from?, body, created_at}.kindissealed(a party wrote it:fromis its agent id andbodythe sealed message) orsystem(the server wrote it:bodyis{event, reveal_id}, whereeventisreveal.requested,reveal.approved,reveal.declined, orreveal.revoked). Every step of a reveal between the two parties is written into their thread as a system message, and a reveal request from a party who has no thread yet on that desire opens one, with the system message as its first message, when both parties have keys.GET /v1/threads/{id}returns the thread and its messages oldest first, with a cursor;POST /v1/threads/{id}/messagesadds a sealed one. - Events. A sealed message puts
thread.messagein the other party's inbox, with the message. A system message sends no event of its own: the reveal event already reached both parties. - Close and report. The owner can close a thread with
POST /v1/threads/{id}/close. A closed thread is final and refuses new messages withconflict. Either party can report a thread withPOST /v1/threads/{id}/reportand{reason, messages: [{id, key}]}: the server cannot read the thread, so the reporter sends the key K of each message it objects to (threadMessageKeyopens it from the reporter's wrap), at most 60 of them. The server opens each named message with its key and the thread id, stores the text it opened with whether the writer's signature holds, and refuses a key that does not open that message (invalid_fieldonmessages.N.key) and a system message, which the server wrote (invalid_fieldonmessages.N.id). A report therefore holds what the writer wrote, never words the reporter typed, and a key reveals that one message and no other. A report closes nothing by itself; the operator reads it. A thread that the caller is no party to answersnot_found, so a stranger cannot learn that it exists. - Lifetime. A thread and its messages are kept 90 days after the last message, then deleted. A thread on a desire that is fulfilled, withdrawn, or expired stays readable and takes messages until then, so the two sides can finish what they started.
5. Search and matching #
5.1 Query #
GET /v1/search with query parameters, or POST /v1/search with the same names in JSON.
| Parameter | Meaning |
|---|---|
q | Free text, up to 500 chars. Searched by meaning (embedding) and by words (full text), fused by reciprocal rank. A meaning hit counts when the record's text measures a cosine of at least 0.62 against q, the meaning floor. A words hit counts when the words of q it shares weigh, by their rarity, at least as much as one word that 10 records hold (each word weighs the log of how many times fewer records hold it than the whole set), or when its own text measures at least the meaning floor, or when its first tag, the plain name of its thing (2.1), equals a word of q: a tag is the poster's own keyword, drafted in English for every language so that it can be matched, so "flat" finds a Spanish record tagged flat whose text measures under the floor, while a later tag names a detail ("used", a town), and sharing it is no find. One rare word is a find in a query of any length ("nanny" in "nanny for a toddler", a code or a name the meaning model does not know), and one or two shared common words ("old" in an old tattoo, "small" and "team") are not. A word of a script written without spaces between words, or with particles joined to them (Chinese, Japanese, Korean, Thai, Lao, Khmer, Burmese), is found inside the text, from two characters up, since the words of such a script are not split apart ("ドキュメンタリー" finds "ドキュメンタリーと企業映像"). A query with no fitting record therefore comes back empty. A place written in q is a word like any other: only where or near holds the records to a place, and understood stays empty for it (SEARCH_PLACE_WORDS); the lookup of other marketplaces alone reads a place from the words (5.6). |
side, kind | Exact filters. kind takes one kind or a list. |
tags | All listed tags must be present. One tag or a list. |
where | A place in any form from 2.4. Records must be compatible with it, with one difference from matching: a query place of remote asks for remote work, so it keeps only remote records (their countries checked as in rule 2), where two records match remote with any place. include_remote=false drops remote and anywhere records. A record with no place is compatible with every place (2.4, rule 1); include_unplaced=false drops it. When the query names a place, the records rank by how exactly they meet it before the sort orders them, in every sort: first a circle or a subdivision of one country, then a whole country or a named set of countries, then remote and anywhere, and last the records with no place (placeTier in the protocol package). A search for Oakland shows the records in Oakland before those across the United States, and both before remote work. |
near, radius_km | Shorthand for a circle: near=37.80,-122.27 or near=Oakland. |
when | A window in any form from 2.5. Records must be compatible with it. |
price_min, price_max, currency, per | Records must be price compatible (2.3): a record with no price, or a price in another currency or unit, passes. |
attr.{key} | Exact attribute match. attr.bedrooms=2. Suffixes .gte and .lte for numbers. In a JSON body the same filters can be sent as a list, attr: [{key, op, value}] with op one of eq, gte, lte: {"attr": [{"key": "bedrooms", "op": "eq", "value": 2}]}. |
basis | stated to drop inferred desires. |
agent | Records of one agent. |
posted_after | Datetime. For polling. |
external | false keeps results from other marketplaces out of the answer, see 5.6. |
sort | relevance (default with q), recent (default without; most recently confirmed first, see 2.6), distance (needs a circle), urgency. |
limit, cursor | 1 to 100, default 20. Opaque cursor. A search without q pages through every open record that meets the filters, however old; a search with q pages through the candidates of its two scans (at most 100 each). |
5.2 Result #
{ "results": [ { "desire": { ... }, "score": 0.83, "similarity": 0.91, "distance_km": 4.2,
"why": ["meaning", "place", "window"] } ],
"next_cursor": "...", "took_ms": 84,
"understood": { "where": { ... }, "when": { ... } }, "external": [], "lookup": null }external holds results from other marketplaces, beside results and never among them, see 5.6; it is always present, and empty in the answer itself. lookup names the lookup of other marketplaces the search started, or is null when it started none; GET /v1/external/{lookup} returns its results (5.6). understood echoes how the server resolved shorthand, so an agent can check that "Oakland" became the Oakland it meant. It is always present, and empty when nothing was resolved. next_cursor is a string or null, never absent. why is a list from meaning, words, counterpart, place, window, price, tags, attributes. place means both sides state one, and window means both name a date (a cadence alone is no window); price means the two prices were compared (2.3). similarity is the cosine similarity of the record's text to q, present on every hit of a search with q.
Score for relevance: the fused text score, 0 to 1 (the reciprocal rank fusion of the two scans, divided by the best possible sum), multiplied by the evidence of the query, by a freshness factor 0.5 ^ (days since confirmed_at / 45), by confidence, and by 0.6 when basis is inferred. The evidence of the query says how sure the whole search is: it rises from 0 when the best similarity among its results is 0.5 or less to 1 when it is 0.75 or more, in a straight line, and it is one factor for every result of the query, so it never changes their order. A search whose best result is weak therefore scores low throughout, and a first result scores near 1 only when it is close in meaning. A hit's own similarity does not weigh on its score beyond that: on the search eval the fused rank alone ranks the record a query means above every weighting of each hit's cosine that was tried. similarity is shown on every hit whose similarity the server measured, words hits included, so a caller sees how close each one is. Without q, relevance sorts as recent. The search parameter example=false drops the example records, and example=true keeps only them.
5.3 Matches #
GET /v1/desires/{id}/matches finds the matches of one desire. It runs three scans and merges them:
- this record's
counterpartagainst other records'text, - this record's
textagainst other records'counterpart, - this record's
counterpartagainst other records'counterpart, for desires where two seekers want each other.
It then keeps only records whose roles complement this record: the other side, or on the same side two of the kinds of people: talent with job, connection with connection, and connection with talent or job, because the two sides of one pair (a band that seeks a bassist, a bassist who seeks a band) can be read into neighbouring kinds. Two wants for the same thing are rivals, and an embedding cannot tell a rival from a counterpart, so the rule must. Scan 3 runs only between connection records. It keeps only records that are compatible in place, time, and price, and drops records of the same agent: an agent that posts for itself never matches its own records. An agent that the operator marks as posting for many people (the examples agent of section 8 always is; others are named in the server's configuration) posts records of different people, so its records match each other as any two agents' records do. It marks excluded: true with excluded_phrase when a candidate's text or tags contain one of this record's exclusions or the reverse. The test lowercases both sides, folds simple English plurals (agencies hits agency), and needs the whole phrase as adjacent whole words.
A match is a search hit plus excluded, a boolean present on every match, and excluded_phrase, present only when excluded is true. Its similarity is the best cosine similarity across the scans, measured exactly by the server against the stored vectors of every candidate the index returns (the index's own score is approximate), and a candidate under the match floor is no match. The floor is MATCH_FLOOR in the protocol package, set by measurement and recorded in docs/spec-notes/api.md: 0.68 is the lowest floor at which at most one match in twenty among the top three of a sample is wrong. A match's score is its similarity weighted by freshness, confidence, and basis as in 5.2.
When a desire is posted, the server runs the same match and returns the top results in the response under matches. It also puts a match.found event in the inbox of each matched owner whose match scores 0.72 or more (NOTIFY_SCORE, stricter than the match floor, so a notice goes out only for a strong match). A new desire therefore reaches the people who were waiting for it. Example records (see 8) appear in matches and never send an event; example=false on GET /desires/{id}/matches drops them, and example=true keeps only them, as in a search.
Every match.found carries attribution, the line that names the product: "Found through Desire DB, the open database of what people want and what people offer, desiredb.com" (ATTRIBUTION_LINE in the protocol package). An agent that relays a match to a person keeps the line.
A record that carries via (2.1) answers a record it was posted from. When the two complement each other in role and no exclusion keeps them apart, the owner of the record named in via receives match.found whatever the score, with the similarity the match found, or the similarity of the two records' vectors when the pair sits under the match floor. A via that names the poster's own record, a record that is not open, or an example record sends nothing. When the record also carries campaign_id, the event carries it too, so the owner knows a campaign found it (5.5).
When a post finds no match, the response carries invite: the address of the record's invite page, {server}/i/{id}, which tells a stranger in one screen what someone near them wants and how to answer in one sentence. The agent sends it to whoever the person names.
5.4 Subscriptions #
A subscription is a saved search: an agent says what it is waiting for, and the server tells it when a record arrives that meets it. A subscription is what the CLI and the app call a watch: subscription stays on the wire (the routes, the sub_ ids, the subscription.hit event, the MCP tool names), and every sentence written for a person says watch.
POST /v1/subscriptionstakes the filter fields of 5.1 (q,side,kind,tags,where,near,radius_km,include_remote,include_unplaced,when,price_min,price_max,currency,per,attr.{key}or the listattr: [{key, op, value}],basis) plusdeliver(inboxorwebhook; defaultinbox, orwebhookwhenwebhook_urlis sent),webhook_url, andmin_score(0 to 1, default 0.62: a search phrase sits further from the record it means than a counterpart sentence does, so this is measured apart from the match floor of 5.3, seedocs/spec-notes/api.md). The paging, sort,agent,posted_after,example, andexternalfields of a search are not filters and are refused. At least one filter that chooses records must be present (q,side,kind,tags,whereornear,when, orattr); a subscription with none isinvalid_fieldonq.basis,price_min,price_max,currency, andperonly narrow one of them: nearly every record is stated, and a record with no price passes every price (2.3), so alone they would fire on nearly every record.whereandwhenare resolved as in a search and meet records as in a search (a place ofremotekeeps remote records only), and the stored subscription shows them in their stored forms, withnearandradius_kmfolded intowhere. Awebhook_urlof its own is admitted as a profile URL is (section 3: the host, the resolved address, and the verification post), when it is new or changed. An agent holds at most 20.- When it fires. When a record is posted or changed, the server runs it against every live subscription: the structured filters first (side, kind, tags, place, window, price, attributes, basis, as 5.1 defines them, with the defaults of a search:
include_remoteandinclude_unplacedare true unless the subscription sets them false), then, for a subscription withq, the cosine similarity betweenqand the record's text at or abovemin_score. The threshold is thesimilarityof the hit, never itsscore, which also carries freshness, confidence, and basis (5.2). A subscription with noqfires on the filters alone. Each hit putssubscription.hitin the subscriber's inbox withsubscription_idandhit, a search hit of 5.2 for the record. A record fires one subscription once, and a subscriber's own records and the example records fire nothing. The match on post (5.3) and a subscription are one mechanism: a matcher that takes a new record and returns the parties to tell, whether they wait with a record or with a query. - Delivery.
inboxputs the event in the inbox, where the poll and the stream find it.webhookalso pushes it, to the subscription'swebhook_urlor, when it names none, to the profile's; a subscription withdeliver: webhookand no URL on either isinvalid_fieldonwebhook_url. The push follows 4.3, signature and retries included. - Lifetime. A subscription expires 30 days after the agent's newest open record expires, or 30 days after its own creation when the agent has no open record, whichever is later. A post or a renew of any record (2.6) moves the expiry of every subscription of that agent, and an expired subscription is removed by the sweep.
GET,PATCH, andDELETE /v1/subscriptions/{id}read, change, and end one; in a patch an absent field stays, null clears a filter, and the whole subscription is checked again. A subscription that the caller does not own answersnot_found, so a stranger cannot learn that it exists. The subscription object is{id, agent, query, deliver, webhook_url?, min_score, hits, last_hit_at?, created_at, updated_at, expires_at}.
5.5 Campaigns #
A desire can sit with no match because the other side is not in the network yet. An intermediary is an agent that goes and finds that side: it searches its own sources, writes to people in its own name, and posts each person who says yes as a record that answers the desire. A campaign is one such search for one desire, agreed between the owner of the desire and the intermediary. Desire DB does not see how the intermediary finds people. It records the terms, the steps, and the records that come back.
- Who. An intermediary is an agent whose profile
kindisintermediary(section 3). It proposes a campaign withPOST /v1/desires/{id}/campaigns; the owner of the desire approves or declines it. An agent of another kind is refused withconflict, and the message namesPATCH /v1/agents/me. A campaign on the caller's own desire, on an example record, or on a desire that is not open is aconflict. One intermediary holds one open campaign per desire: a second proposal is aconflictthat names the open one. A campaign is open while it isproposed,approved, orrunning, and the open ones count toward the limits of 6.2. - The campaign object is
{id, desire_id, intermediary, status, fee, found, contacted, posted, created_at, approved_at?, started_at?, done_at?}.statusis one ofproposed,approved,running,done,cancelled.foundandcontactedare the intermediary's own totals of the people it found and the people it wrote to.postedis counted by the server: the records posted for the campaign.done_atis when the campaign ended, done or cancelled. - The fee is
{type, amount, currency}, or null for no fee.typeissuccess, paid only when a deal closes with someone the campaign found, orflat, paid for the campaign itself.amountis a number above 0 incurrency, a code of 2.3. The intermediary sets it in the proposal and cannot change it: to offer other terms, it cancels and proposes again. The owner accepts the fee by approving. Desire DB records the terms; the money moves outside it.
The steps. Each is one call, and each from any other status is a conflict whose message names the status it needs (CAMPAIGN_STEPS and campaignStepConflict in the protocol package). A step made by the other party is not_owner, and its message says which steps are that party's. Each answers the campaign after the step, with its fee, so an approval answers with the fee the owner accepted:
| Call | Who | From | To |
|---|---|---|---|
POST /campaigns/{id}/approve | owner | proposed | approved, and sets approved_at |
POST /campaigns/{id}/decline | owner | proposed | cancelled |
POST /campaigns/{id}/start | intermediary | approved | running, and sets started_at |
POST /campaigns/{id}/progress | intermediary | running | running, with new totals |
POST /campaigns/{id}/done | intermediary | running | done |
POST /campaigns/{id}/cancel | intermediary | proposed, approved, or running | cancelled |
A progress call sends {found, contacted}, one or both, as the totals so far; a total below the stored one is invalid_field. When the desire stops being open (fulfilled, withdrawn, or expired), the server cancels its open campaigns.
- Posting back. The intermediary posts each person who says yes as a record with
viaset to the desire andcampaign_idset to the campaign (2.1). The campaign must be running and be the caller's, andviamust name its desire; otherwise the post is aconflict. The contact of that person goes insealedlike any other, encrypted and signed by the intermediary as the writer (4.2): the person's own contact, or the intermediary's when the person prefers. The record counts once towardposted; a post with itsrefagain does not count it again. Thematch.foundthatviasends the owner (5.3) carriescampaign_id. - Who reads what.
GET /v1/campaigns/{id}answers either party, with the fee; anyone else getsnot_found.GET /v1/desires/{id}/campaignslists the campaigns of a desire, newest first: the owner sees every one with its fee, and anyone else sees the approved and running ones without the fee, except that an intermediary sees the fee of its own.GET /v1/campaigns?mine=1lists the campaigns the caller is a party to, as owner or as intermediary. Both lists takestatus=,limit=1 to 100 (default 50), andcursor=, andmine=1also takesdesire=. - Events.
campaign.proposedgoes to the owner,campaign.approvedandcampaign.declinedto the intermediary, andcampaign.doneto the owner. Each carriescampaign_id,desire_id, andcampaign, the campaign as it stood, with its fee. A cancel sends no event; the other party reads it from the campaign.
5.6 Results from other marketplaces #
Most wants and offers already sit in other marketplaces: classifieds, freelance boards, job boards, rental sites. Desire DB does not copy them. When its own records cannot answer a question, the server asks those marketplaces at that moment, shows what they return beside its own results, and keeps only what someone acts on (docs/V2.md section 8).
- When. A search asks when it has
q, is its first page (nocursor), and finds fewer than 3 results of its own that hold its words, in all, on every page (EXTERNAL.own_results_belowin the protocol package): a small page of a search with enough records asks nothing. A result holds the words when its text and its tags pass the word test every listing passes (holdsWords, under The result): every word of a query of one or two, two thirds of a longer one. A record that shares one word, or is found by meaning alone, is near the query, not the thing it names, as a card program is near "Pokemon cards", so it does not keep a search from asking. A post or a patch asks when the record finds fewer than 3 matches. A call withexternal=falsenever asks, nor does a search withexample=true, an example record, or a dry run.externalis a yes or no parameter of a search (a query parameter, or a field of the JSON body) and a query parameter ofPOST /v1/desiresandPATCH /v1/desires/{id}; a subscription refuses it, as it refuses the other fields of a search. - Beside the answer. A lookup runs beside the answer that starts it, never inside it (lead decision 9). A search or a post answers at once with its own records,
externalempty, andlookup, the id of the lookup it started (lku_and 20 characters), or null when it started none. When the answer would have asked and could not because the caller's address has started its hourly most of lookups (6.2), it also carrieslookup_error, therate_limitederror of 6.1 with itsretry_after, whose message says why the limit exists (each lookup pays for search engine calls and a model's reading of every listing it finds,EXTERNAL_LOOKUP_LIMIT_REASON), so a client can tell a limit from nothing to ask and say why and when to try again; the answer's own results stand.GET /v1/external/{lookup}answers{lookup, status, external}: the results so far, andstatusdonewhen every source answered or ran out of time, elseworking. It waits up to 8 seconds (EXTERNAL.wait_max_seconds,?wait=from 0 to 8) while sources still work, and can be called again. A lookup keeps its results 10 minutes (EXTERNAL.lookup_kept_minutes); after that, or for an id that never was, the call isnot_found. It needs no token: the id is the only way to reach a lookup. The agent whose call carried a token receiveslookup.readywhen the lookup is done (4.3). The MCP tools of a search, a post, and a patch wait for the lookup, reading it again while it isworking, and return the answer withexternalfilled andlookup_status, the lookup's laststatus, so an agent still makes one call and can tell a lookup that found nothing (done) from one that did not finish in time (working); the CLI prints the answer at once and the Elsewhere block under it when the lookup isdone, and the app and the front page show their own results first and fill the Elsewhere block as its results arrive, reading again until it isdone. A lookup that ends with no result says so in the block: that no listing fits when it isdone, and that other marketplaces did not answer when it could not be read to its end. An answer withlookup_errorshows, in the Elsewhere block's place, that other marketplaces were not asked, why, and when a search can ask them again, in the same words on every client. - Time. A lookup gets 20 seconds from the answer that started it (
EXTERNAL.deadline_ms), for all of its work: the reading of its words, every source's answer, and the reading of every listing. Its sources are asked at once. A listing not read by then is dropped, and a marketplace that fails adds nothing: an answer never fails because a marketplace did. A lookup isdoneat the latest a few seconds after its deadline: a read then answersdonewith the results saved, even when the Worker that ran it stopped before it could say so. The server logs one line per source and lookup, and a source the registry switches on whose API needs a key the server lacks is logged asno_key, so it never goes unseen, and is not asked. Each search engine call logs one line with the links the engine answered before any test and the links each entry kept, so an engine that answers nothing reads apart from links the tests turned away. - Which marketplaces. The registry,
scripts/import/marketplaces.json, holds one entry per marketplace (MarketplaceSchemain the protocol package): its kinds and the side its listings are on (want,offer, orboth), in the roles of 2.2 that its listings become as records, so a board of employers' job ads holds wants of talent, and a site of offers with a jobs section holds both sides, the countries it serves (*for every country), the languages of its listings, its route, its terms, its switchenabled, andquery_budget_per_day, the most lookups the server makes in it in one UTC day. A lookup asks the entries that the server asks now, have budget left today, hold one of the sides and one of the kinds it looks for, serve every country or a country of its place, and, for a marketplace that holds only some things (itsthings, English tags such asbookorwatch), hold the thing the lookup looks for: one of the lookup's tags holds one of its things as whole words, singular or plural, a piece of work holding the word it is made from (holdsThing, bysameWord, under The result), so a road bike asks no bookshop and an electrician no language school (pickMarketplaces). The server asks an entry now when the registry switches it on and does not exclude it (switchedOn), something reads it (its API adapter, or the search engine forsearch_index), and the key that reader needs is set (sourceState, the one rule the lookup, the act, and every result'sstorableread): an entry whose key nobody has set is off, with that reason, and goes on by itself the day its key is set. The registry notes of such an entry name the key and where to sign up for it. A search looks for its ownsideandkind; for a kind of people it also looks for the same deal read from the other party (askOfSearch): a want of talent, an employer's ad, is an offer of a job, and a want of a job, a seeker's post, is an offer of talent, so a search for offers of a job finds employers' ads. A search that names no side and looks for a job (its kind, named or drafted, isjobalone) looks for positions on offer: offers of a job, with their mirror, employers' wants of talent (askOfJobSearch). A job is a position for the one who looks, so "React developer job" finds employers' ads and never another developer's resume, which is the same deal as the searcher's own. A search of any other kind that names no side keeps both sides. When a lookup of a search runs, the reader drafts from its words, in one call, the kind and the tags it drafts for a post (2.1, 2.2), told that they are the words of a search: they name what someone looks for, never a page, so they always get tags, and after the first tag the other plain English names a listing may use for the same thing ("bicycle" beside "bike"). A search that names no kind asks the marketplaces of the drafted kind, with the kinds paired with it in 5.3, and every marketplace when the reader drafts none: "used iPhone 15" asks sites of goods, not the rooms, jobs, and pet sitters a lookup of every kind would fill its calls with. A listing then passes the search's test of kind only when its kind is of the same nature as the drafted one (kindsOfNature): a thing handed over (good,space,capital), or the work or the time of people (service,talent,job,knowledge,connection,experience);otherstands alone. A reader tells a thing from a person's work far more surely than a lesson from a service, so "piano teacher" keeps a tutor however the lessons are filed and no piano for sale, and "Laptop gebraucht" keeps no laptop repair. The tags name the thing the search looks for; a post's own tags name the thing it looks for. When the reader gives a search no tags, its words, as one tag, are the thing, so a lookup never keeps a listing of some other thing. A post looks for the records that would answer it (5.3): the other side of its kind, and fortalent,job, andconnectionalso the same side of the paired kinds, and it asks only the entries that hold one of those roles (a pair of side and kind), so an employer's post asks no board of other employers' ads, and a job seeker's post asks every one. A query whose place names no country asks the entries of every country. A search that sends no place (nonear, nowhere) and names one in its words ("Wohnung mieten München") is read with that place: the reader drafts it with the kind and the tags, the gazetteer resolves it as it resolves a post's drafted place, and the lookup then asks the entries of its country, tests every listing against it as against a place the search sent, and sends the town as the searcher wrote it. Its name leaves the words a listing must hold, since a listing seldom names its own town (the longest run of up to three words the gazetteer reads as a place inside the drafted one,placeInWords); words of a script written without spaces keep it. A search sends itsqas the words to look for; a post sends the name of its thing among the English keywords the reader drafted (2.1): its first tag, or a later tag that names a kind of it with more words ("electric guitar" after "guitar"), never a brand or a detail beside it, since a marketplace's search wants every word to match and a brand hides the other brands (thingName); a post with no tags sends itscounterpartor itstext. - Routes.
apiasks the marketplace's own search API, held to the lookup's place where the API takes one (lookupPlace): a job API gets the town of a circle and its radius (USAJOBS the town with its state, or with its country abroad, and the radius in miles rounded up; Adzuna the town and the radius in km on its own country's endpoint), eBay the country it delivers to, the lookup's or else the site's, since a good ships, and SAM.gov the state of a place in the United States, since a notice is often placed by its state alone. An announcement of many places is named by the one nearest the circle, and so is the record an act stores from it while the lookup that showed it is kept (by the place of the result clicked); after that, by the source's own order.search_indexasks a search engine limited to the marketplace's site (the entry'ssearch_index_hint): the server never fetches the listing, and reads the title, the snippet, and the address of the page only to fill the fields of its reading; none of those words is shown. The reader gets the title and the snippet each named: the title and the address are the ad's own, while the snippet can come from anywhere on the page (its menus, other ads beside it, a bid or a reply), so where it speaks of another thing, place, or price than the title and the address, no field comes from it. The hint of an enabled entry says which pages of the site are listings, never the whole site, since a site's category, search, and help pages hold the query's words too and read as listings: a path on the site (site:gumtree.com.au/s-ad/), aninurl:part, or aurl:pattern, a regular expression that the host and the path of a listing match when the listings of a site share no path (site:argenprop.com url:--\d+$); the engine never sees aurl:pattern. A site whose listings no address picks out is switched off. A page whose address carries the words of a search (a parameter such assearch,q, orquery) is the site's own search and never a listing, whatever its path. A path holds the pages under it, and the page at it only with parameters that name one listing (indeed.com/viewjob?jk=): the page at the path alone is the index of that section, a page of many ads (dribbble.com/jobsforsite:dribbble.com/jobs). The sites of the chosensearch_indexentries are joined into at most 8 engine calls per lookup, each held to at most 3 sites, since with more one site fills the ten links a call answers; an entry withinurl:parts gets a call of its own, a whole site, held to no path, shares a call only with entries of the same sites, since it fills a call with its other pages; an entry whose hint names a site for each of its countries (vinted.it,it.wallapop.com) sends only the sites of the lookup's countries, and at most as many sites as one call holds; and an entry of every country never shares a call with the entries of the lookup's country, since a call answers at most ten links and a catalogue of every country would fill them. Entries of the lookup's country come first, but a lookup of a country keeps a third of its calls for entries of every country when it has them, since a catalogue of every country (Indeed for a job in Houston) often holds the most listings; a lookup whose place names no country asks the entries of every country first. Among entries of one reach, those that hold more of the kinds asked come first, then those given more wholly to those kinds (a job board before a site of jobs and services). The words it sends are the lookup's, and for a place that is a circle also the town, as the searcher wrote it when the search named its place in words ("Roma", not the gazetteer's "Rome", which on an Italian site finds shirts printed with the name). A link loses the parameters that follow a click rather than name a listing, the engine's (srsltid), the campaign tags of any link (utm_and the like), and the ones a site adds to say where a click came from (referrer,clickType,searchVariation), so one listing keeps one url. A link counts only when it holds the words of the lookup, as every listing must (the word test under The result), where its title and its url are its own words and the snippet, which can come from anywhere on the page, counts only toward the whole query. Only the entries that get a call count against their budget. With no search engine key set, nosearch_indexentry is asked.excludedis never asked; a site whose terms forbid automated access is reached only assearch_index(decision sd-275). - The result. A listing is read only when it holds the words of the lookup, each counted as the words scan of 5.1 finds it (a word of a spaced script as a whole word or a word one with it by
sameWord, so "bikes" holds "bike" and "carpentry" holds "carpenter", while "car" is not in "cardigan"; a word of a script written without spaces inside the text, and a number joined to such a script's words, as in "아이폰15프로", as a number of its own): the word of a query of one, one word of two, two thirds of more, and always every word that holds a digit, from two characters up, since a number names the model, the size, or the year asked for ("used iPhone 15" is not held by a part for an iPhone 14), and every word of a search that names a make or a model, which the reader marks when it reads the search's words ("Trek" in "Trek road bike",marks), since a listing of another make answers no search for this one. One word of two is enough here because the reader reads the listing next and keeps it only when it names the thing (below): an ad for a "Laptop" answers "Laptop gebraucht", and "mieszkanie na wynajem" answers "mieszkanie do wynajęcia", while an ad of "road" alone for "road bike" names no bike. A record of its own holds a query of two only with both words (When, above). This one test (holdsWords, byWORTH_READING) applies to every source, since many match any one word, forgive typos, or answer a board read whole. A listing whose words come in part from around the ad, a search engine link, is held to its own words, its title and its url: one word of two is enough only when they hold it, since the reader names the listing by them, while the snippet, which can come from the page's menus or another ad beside it, counts only toward every word of a query of two and two thirds of a longer one, and a word with a digit is always in its own words (a Chrono24 page of an Omega whose menu names Rolex does not hold "Rolex Submariner", and a markt.de iPhone 15 whose snippet names an iPhone 14 does not hold "iPhone 14"). The words of a post's lookup are English tags, which a listing in another language never holds while a search engine finds it by their meaning, so a post's lookup holds no listing to them, and the reader's test of the thing (below) decides alone. For the same reason, the words of a search hold a listing of a site that serves none of their language (the reader drafts the language of a search's words with its kind and its tags, and each entry names itslanguages) only to their words with a digit and their marks: "apartment for rent" is held by no Portuguese title in Lisbon, which the engine found by meaning. Then every listing passes through the reader that fills a posted record (section 2), which drafts every field the listing leaves empty (a listing states its side only when its marketplace holds one side alone, and never its kind: a marketplace's kinds say what it mostly holds, not what one page is, since a site of goods also carries lessons; the lookup's ask never decides either), place, time, and price included, and then through the filters of 5.1 (a search: itssideandkindwith their mirror above, place, time, and price) or the gate of 5.3 (a post: the roles that answer it, and place, time, and price), exactly as a record does; a listing outside the query's place, time, or price is dropped. The reader reads a page of another marketplace as a listing only when it is one person's or one organisation's own ad, open now; a question or a discussion, an article, a member's profile, a page that gathers many ads or firms, and an ad whose words say it was taken down, has expired, or is sold get no tags, and a listing with no tags names no thing, so it never counts. The reader is told whether the site serves one country or several, by the site of the link: a site the entry's hint names for one country (it.wallapop.com,propertyfinder.ae) serves that country alone, whatever other countries its entry serves (linkReach); on a site of several, a town's name alone, a capital's included, says no country, and a listing whose words give its town but neither its country nor its state is not placed (placeAttempts), since the largest of the town's namesakes is only a guess: a nurse's job in a Dublin of Ohio is not shown for Dublin in Ireland. A part, an accessory, or a toy of a thing is read as that part, never as the thing: tuners for a guitar are tuners. A listing counts only when it names the thing the lookup looks for: the first of its tags, the plain name of its thing (2.1), ends on the word one of the lookup's tags ends on, singular or plural, passing over a number after it, or is one of them written with or without spaces (namesTheThing). When that tag names a kind of the thing ("cargo bike") and the listing names a kind of it too ("e bike"), the listing's tags must also hold the tag's words: an e-bike is a bike but not a cargo bike, while a listing that says only "bike" may be either. The lookup's thing is its first tag, or a later tag that names a kind of it with more words (thingName: "road bike" beside "bike"); a tag of the lookup that holds only words of its thing names a wider thing ("bike" beside "road bike" or "cargo bike") and does not count, so a gravel bike never answers a road bike, and a lookup with no tags keeps nothing. The kind a search looks for is the one its searcher named: of the reader's tags of a search in English, a kind of the thing whose words the search does not hold is left out (searchThing), so "Canon AE-1" looks for a camera, and an ad of an analog camera answers it although the reader knows the AE-1 takes film; words of another language hold no English word of a kind, so their tags all stand. A word that names a piece of work is one with the word it is made from and with the word for one who does it (sameWord): an ICU "nursing" job answers an "ICU nurse", "plumbing" a "plumber", and "carpentry" a "carpenter"; a thing and one who does something with it stay apart, so a "printer" is no "print". The word a name ends on says what the thing is: a "wedding dress" is a dress, and a "phone case" is a case, not a phone. A page that holds the word "plumber" but sells search ads to plumbers is tagged "seo" first, a Lego set that holds "Fender Stratocaster" is tagged "lego" first, and a brooch shaped like a bonsai is tagged "jewelry" first; tags are English for every language, so a Polish "mieszkanie" meets an "apartment". When the search or the post names a place (a circle or a region), a listing the reader could not place is dropped too: a record of Desire DB with no place fits anywhere, but a listing with no place is one we could not place, and a site that serves every country holds far more listings elsewhere than here. A listing lies in one town, and its circle is only the size of that town, so when the place asked is a town, a listing is in it only when the reader placed it in a town, that town lies inside the circle asked, and, when both name a state or a province (aregion), it is the same one, even where the two circles meet: a flat in Sharjah or Ajman is not in Dubai, and a nanny's job in Leigh-on-Sea, 55 km out, is not in a 40 km London. A listing placed only in a whole country or state names no town, so no one can tell it is in the town asked, and it is dropped: a job placed in Germany does not answer a search near Berlin. A good that the marketplace's API says it delivers anywhere in a country is placed where the buyer receives it, that whole country (2.4: a region is the place of shipping), and by rule 4 of 2.4 it meets a circle inside that country, while its line says that it ships there and from where it lies: a road bike on eBay that ships across the United States answers a search near Denver. Pickup near the searcher is not asked for: it needs a postal code the gazetteer does not hold, and a seller who ships fulfils in the searcher's own town. One listing counts once: the same path of one marketplace under two of its hosts (a language subdomain, a mirror) is one listing, and a result whoseurla record of the answer already holds (a listing someone acted on earlier) is left out. An answer holds at most 10 (EXTERNAL.results_max), at most 3 from one marketplace (EXTERNAL.per_source_max), taken from each marketplace in turn. The answer ofGET /v1/external/lku_4q7nmh2c5xw3s6ptdk2e:
{ "lookup": "lku_4q7nmh2c5xw3s6ptdk2e", "status": "done",
"external": [ { "source": "ebay-us", "source_title": "eBay United States", "id": "v1|110554234567|0",
"url": "https://www.ebay.com/itm/110554234567", "text": "Used road bike with a 56 cm frame, for sale",
"side": "offer", "kind": "good", "where": { "type": "region", "countries": ["US"] },
"route": "api", "storable": true, "fetched_at": "2026-09-28T10:00:00Z" } ] }external sits beside results in a search and beside matches in a post or a patch, and is never mixed into them. It is always present: empty in the answer itself, and filled in the lookup's own answer and in the answer of an MCP tool that waited for it. source is the registry name, the same name the importers of docs/V2.md section 6 use; id is the marketplace's own id of the listing; for a search_index result it is the url without https://, or a hash when the url is too long. text of an api result is the listing's own words, as an importer stores them. text of a search_index result is our own line, written from the side and the first three tags the reader drafted (or the kind, when it drafted no tags) and never from the snippet: "Offered: cargo bike, urban arrow". The reader reads only what the listing itself says, never the words of the page around it (the site's name, its sections, other listings beside it) nor the words of the query, so the line describes the listing; and it places the listing where it is, never by a place printed on the thing or named in a product's or a firm's name. The words it reads are never shown, so the checks for contact detail and for personal data (6.3) skip them, and a listing whose snippet holds a phone number is still a result; every field it fills is checked. where, when, and price are present when the reader found them. source_title names the marketplace, or the part of it the listing sits in when that has a name of its own, such as the Hacker News thread of the post. storable says whether an act on the result stores it (Storage, below): true only for a result of route api whose marketplace this server reads now, can read one listing of again by its id, and has an importer agent for. A client offers to keep only a result that is storable. A result carries no contact.
- Attribution. Whoever shows a result names its source (
source_title) and links itsurl. The listing stays the marketplace's, and a person answers it on the marketplace. - Storage. A result of route
search_indexis shown live and never stored, not even when someone acts on it (decision sd-286;STORED_EXTERNAL_ROUTESin the protocol package is the one place this rule lives). A result of routeapiis stored only when someone acts on it:POST /v1/externalwith{source, id, action}, whereactionisopen(someone opens it),contact(someone asks for its contact), orcampaign(someone starts a campaign on it). Anopenneeds no token, so a visitor who never registered reports it too, and it is counted by address then;contactandcampaignlead to calls that need a token, and need one. The server reads the listing again from the marketplace's API, never from the caller, checks it by the content rules of 6.3 as a post, and stores it under that API's terms as a record of the source's importer agent:basisinferred,confidence0.6,ref{source}:{id},urlthe listing,attributes.sourcethe source, and an expiry 7 days after the act (EXTERNAL.record_expiry_days). A second act on the same listing reads it again into the same record and moves the expiry. The answer is{desire}, with status 201 when the record is new and 200 when it was there. The record then takes the calls of any record: a reveal request asks for its contact (4.3), and an intermediary can propose a campaign on it (5.5). An act on a result of routesearch_indexis aconflictwhose message names itsurl(from itsid), to open instead, when that url is a listing of the marketplace by its hint; the url of a hash is the url of a result that a lookup still held showed with that source and id, whose url hashes to it; an id that is neither names no url. An act on anexcludedmarketplace is aconflictthat says the server never asks it. So is an act on a marketplace the server does not ask now (sourceState: its switch is off, nothing reads it, or the key it needs is not set, and the message names that key), or on one whose API cannot read a single listing by its id. A listing the marketplace no longer holds isnot_foundonid; a marketplace that does not answer the read isserver_error; a server that holds no importer agent for the source answersnot_implemented. Nothing is stored in any of these cases. - Contact. The server holds no key of the importer agent, so it stores the record with no sealed envelope. The importer agent, on the machine that holds its keys, finds its records without one (
GET /v1/desires?mine=1), reads the listing's contact route (its reply route, or a contact the poster published in it), and seals it to its own key with a patch, signed as any envelope is (4.2). Until then a reveal request on the record is aconflict, as on any record with no envelope. The server never stores a contact in plain text.
6. HTTP interface #
Base path /v1. JSON in and out. All times are UTC ISO 8601.
| Method and path | Auth | Purpose |
|---|---|---|
POST /agents | none | Register, see 3 |
GET /agents/me, PATCH /agents/me | token | Read or edit the profile. Returns the agent |
GET /agents/{agent_id} | none | The public profile, with the keys and the registration that binds them when the agent has keys |
POST /desires | token | Post. Returns {desire, enriched, matches, external, lookup} with status 201, or 200 when a ref replaced a record. invite is added when matches is empty, see 5.3, and place_unresolved when the server read a place from the text that it could not resolve (2.4), with the message and suggestions of where_unresolved. Storing the record is the last step that can answer an error: when a later step fails (matching, the notices of 5.3 and 5.4), the post still answers the stored record, with matches empty and warning, a sentence that says the record stands and how to read its matches later, so a client that retries without a ref never posts it twice. A patch answers the same way. external=false starts no lookup of other marketplaces, see 5.6 |
POST /desires?dry_run=1 | none | Validate and enrich without storing. Returns {desire, enriched}, the record as it would be stored, without an id, and place_unresolved as for a post. For learning the format and for the website demo |
GET /desires/{id} | none, or the owner's token | Read one. Returns the record itself. With the owner's token it includes sealed |
PATCH /desires/{id} | owner | Change any poster field. Null clears a field, and the server fills it again as for a post that left it out (2.6). The server embeds again when text, detail, or counterpart changes, and fills again the fields it had filled before. Returns {desire, enriched, matches, external, lookup}. ?dry_run=1 stores nothing and returns {desire, enriched}, the record as the patch would store it (2.6) |
POST /desires/{id}/renew | owner | Confirm it is still real. Returns the record |
POST /desires/{id}/fulfil | owner | Mark fulfilled. Returns the record |
DELETE /desires/{id} | owner | Withdraw. Returns the record |
GET /desires?mine=1 | token | The caller's own records with their envelopes, all statuses. status=, limit= 1 to 100 (default 50), cursor=. Returns {desires, next_cursor} |
GET /desires/{id}/matches | none | Matches, see 5.3. example= as in a search. Returns {matches} |
GET or POST /search | none | See 5.1 |
POST /desires/{id}/reveals | token with keys | Ask for a reveal, with {message?, from_desire?, intro?, sig?} (4.3). Returns the reveal with status 201 |
GET /reveals/{id} | either party | The reveal: state, envelope, and grant when approved. Anyone else gets not_found |
POST /reveals/{id}/approve, /decline, /revoke | owner | Decide. Returns the reveal |
GET /inbox | token | {events, next_cursor}, at most 100 events, kept 90 days. Events: match.found, reveal.requested, reveal.approved, reveal.declined, reveal.revoked, subscription.hit, thread.message, campaign.proposed, campaign.approved, campaign.declined, campaign.done, lookup.ready. ?after=cursor returns only later events. ?wait= takes a whole number of seconds from 0 to 25 and holds the call until an event arrives or the seconds pass, and holds it for the whole wait when nothing arrives. next_cursor is an opaque cursor that marks the position after the last event returned, repeats the cursor sent when nothing is new, and is null only when the agent never had an event |
GET /inbox/stream | token | The same events as server sent events, see 4.3. ?after=cursor or Last-Event-ID resumes; without either, the stream starts at the agent's first event, as the inbox does |
POST /subscriptions | token | Save a search, see 5.4. Returns the subscription with status 201 |
GET /subscriptions | token | {subscriptions}: the caller's own, at most 20 |
GET /subscriptions/{id}, PATCH /subscriptions/{id}, DELETE /subscriptions/{id} | owner | Read, change, or end one. Each returns the subscription |
POST /desires/{id}/threads | token with keys | Open a thread with the first sealed message, see 4.5. Body {body: {v, nonce, ct, sender_wrap, recipient_wrap}}. Returns {thread, messages, next_cursor} with status 201 |
GET /threads | token | The threads the caller is a party to, newest activity first. desire=, status=, limit= 1 to 100 (default 50), cursor=. Returns {threads, next_cursor} |
GET /threads/{id} | either party | {thread, messages, next_cursor}, messages oldest first. after=cursor, limit= 1 to 100 (default 50) |
POST /threads/{id}/messages | either party, with keys | Add a sealed message. Body {body: {v, nonce, ct, sender_wrap, recipient_wrap}}. Returns the message with status 201 |
POST /threads/{id}/close | owner | Close the thread. Returns it |
POST /threads/{id}/report | either party | Report the thread with the key of each message it objects to; the server opens those messages. Returns the thread |
POST /desires/{id}/campaigns | intermediary | Propose a campaign, see 5.5. Body {fee}, where fee is {type, amount, currency} or null. Returns the campaign with status 201 |
GET /desires/{id}/campaigns | none, or a token | The campaigns of a desire, newest first: every one with its fee for the owner, the approved and running ones without the fee for anyone else. status=, limit=, cursor=. Returns {campaigns, next_cursor} |
GET /campaigns?mine=1 | token | The campaigns the caller is a party to, as owner or intermediary, newest first. desire=, status=, limit= 1 to 100 (default 50), cursor=. Returns {campaigns, next_cursor} |
GET /campaigns/{id} | either party | The campaign with its fee. Anyone else gets not_found |
POST /campaigns/{id}/approve, /decline | owner | Approve and accept the fee, or decline. Returns the campaign |
POST /campaigns/{id}/start, /progress, /done, /cancel | intermediary | Move the campaign, see 5.5. /progress takes {found, contacted}, one or both. Returns the campaign |
GET /external/{lookup} | none | The results of a lookup of other marketplaces, see 5.6: {lookup, status, external}. ?wait= 0 to 8 seconds, default 8, while sources still work |
POST /external | token, none for open | Act on a result from another marketplace, see 5.6. Body {source, id, action}, action one of open, contact, campaign. Returns {desire}, the record the result became, with status 201, or 200 when the listing was already a record. A result of route search_index is a conflict |
POST /resolve/where, POST /resolve/when | none | Turn shorthand into the stored form without posting. resolve/where takes {where} in any form of 2.4 and responds with {where} in the stored form. resolve/when takes {when, now} and responds with {when} in absolute dates; now is an ISO 8601 datetime to read the phrase from, default the server clock |
GET /export | none | All open public records as NDJSON, newest first. ?limit= 1 to 5000, default 1000. The header X-Next-Cursor holds the cursor of the next page, sent back as ?cursor=. No header means the last page |
GET /stats | none | Counts of the open records: by_side, by_kind, and by_country, where a record counts once, under the one country its place lies in (a circle, or a region of one country); records that lie in no one country are counted apart as several_countries (a region over more than one), remote, anywhere, and unplaced (no place, or a circle in no country), so by_country and these four sum to total. Also posted_today (records posted since midnight UTC, example records left out) and matched_this_week (match notices sent in the last seven days) |
Every event has id, type, created_at, and, once pushed, delivery (4.3). match.found adds desire_id (the receiver's own desire), match, and attribution (5.3). reveal.requested adds reveal_id, desire_id, requester, enc_key, sig_key, ts, proof, and the optional message, from_desire, intro, and sig (4.3). Read from the inbox or the stream, it also carries reveal_status, the status of the reveal now, and decided_at once the owner decided: the owner's own decision sends the owner no event, so the request shows it. A pushed event carries neither. reveal.approved, reveal.declined, and reveal.revoked add reveal_id and desire_id, and the last two add reason (4.3). subscription.hit adds subscription_id and hit. thread.message adds thread_id, desire_id, and message. campaign.proposed, campaign.approved, campaign.declined, and campaign.done add campaign_id, desire_id, and campaign (5.5). match.found adds campaign_id when a campaign found the record (5.3). lookup.ready adds lookup and count, how many results the lookup holds, never the results (5.6).
Machine readable descriptions: /openapi.json (every query parameter listed), /schema/desire.json, /schema/search.json, /schema/subscription.json, /llms.txt, /llms-full.txt. A request to / from curl, or with Accept: text/plain or text/markdown, gets /llms.txt; the same request to /docs gets /llms-full.txt, and /docs answers without a redirect. The page of one desire, /d/{id}, answers a request from curl, or with Accept: application/json, with the public record as JSON, the same as GET /v1/desires/{id}, and an unknown id with the not_found error object. The page carries a share line, two buttons that open the app with the counterpart of the record or the same record prefilled and via set (/app#post?text=...&side=...&kind=...&where=...&via={id}), and the count of agents whose live subscriptions (5.4) name its kind, or no kind, and a place compatible with its own; the server reads that count at most once a minute for one record, so a page can show a count up to a minute old. The invite page of a desire, /i/{id}, is the same record written for a stranger: who posted it and where, the sentence in the poster's own words, the counterpart they ask for, and one button that opens the app with the counterpart prefilled. The page puts no side in front of the sentence; the sentence and the counterpart line carry it. A malformed id answers as an unknown one. It answers curl and Accept: application/json as /d/{id} does. The skill, the plain text an agent installs to carry Desire DB with it (SKILL.md in the open agent skill format, from the repo ashot/desiredb-skill, npx skills add ashot/desiredb-skill), is served at /skill. The open records are also published once a day as a gzip NDJSON snapshot, the same rows as GET /export, at /snapshots/desires-{YYYY-MM-DD}.ndjson.gz and /snapshots/latest.ndjson.gz, listed at /snapshots/index.json; the page /data states the licence (ODbL 1.0) and the format. A dated file changes only when a record in it is removed on request (2.6), so it is served with a cache of one day, and latest with a cache of one hour. /embed.js is a script that shows a live column of open records on any page (<script src="{server}/embed.js" data-near="Lisbon" data-kind="space">), from the same search; /embed shows it. A person named in a record that an importer posted from a public source asks for its removal at /remove; the page takes an email address and the record or the source, and an operator reads the request and removes the record as 2.6 describes. A remote MCP server at /mcp (streamable HTTP, stateless) offers the read tools and, with the header Authorization: Bearer {token}, the writes. Sealing a contact, asking for a reveal with an intro, approving, opening, and reporting a thread need a key on the client, so those tools live in the local server of the CLI, see 7; the local server runs the same tool table plus those. Declining or revoking a reveal and closing a thread need no key, so both servers have them. Every response under /v1 is JSON, errors included. Every HTML page of the site is served with a Content-Security-Policy that allows scripts and connections from this origin only and forbids framing, because the web client keeps secret keys in the browser.
6.1 Errors #
{ "error": { "code": "where_unresolved", "message": "No place matches \"Oaklnd\".",
"field": "where", "suggestions": ["Oakland, California, US", "Oakland, New Jersey, US"],
"docs": "https://.../docs#where" } }Every error has a stable code, a plain message that says how to fix it (never the raw text of a validator), the field when one is at fault as a dotted path such as price.currency, and docs, which links to the section of this document that defines the field at fault ({server}/docs#price for price.currency, {server}/docs#3-identity-and-auth for webhook_url) and to the page itself otherwise. A where_unresolved error with no suggestions says what to name instead: a city, a country, a named set, or coordinates. Every call refuses a query parameter or a body field it does not read, also a call that takes none (GET /desires/{id}/matches?limit=2, a body on POST /desires/{id}/renew): invalid_field on that name, with the words Unknown field "limit". Remove it, or check the spelling., so a misspelled parameter never passes as if it had worked. A call that reads a body refuses an unknown query parameter too (POST /desires?dryrun=1 stores nothing), and a yes or no parameter (dry_run, mine, include_remote, include_unplaced, example, external) takes true or 1 and false or 0 and refuses any other value, so dry_run=on is an error and never a real post. A key that no form of a field takes is named the same way (where.lng, since no form of where reads lng). When the caller most likely meant a name the call takes, suggestions holds it: the one required key of the same object that is missing (where.lon); a name that means the same thing (radius_km for radius, after for cursor on the inbox and a thread, where or near for location, and sealed for contact, with a sentence that a contact is sealed on the client); or else the one name nearest in spelling. A field that a call refuses on purpose says why: a subscription answers sort, limit, cursor, agent, posted_after, example, and external with a sentence that they belong to a search and a subscription keeps only filters. An item of a list field that the caller wrote as text (kind=spaces, kind=good,bike, or "kind": "spaces") is reported on the field itself, with the value quoted, and an item of a JSON list by its place (kind.1). A fault inside a field starts its message with the dotted path of the field, whichever check found it. A fault in the coordinates of near is reported on near. A post or a patch that sends a field the server sets (status, id, created_at, and the rest of 2.6) is refused with invalid_field on it, and the message names the call that sets it. A path of the API called with a method it does not take answers not_found with the methods it takes. Codes and HTTP statuses: invalid_body 400, invalid_field 422, where_unresolved 422, when_unresolved 422, unauthorized 401, not_owner 403, not_found 404, keys_required 403, rate_limited 429, content_rejected 422, conflict 409, server_error 500, not_implemented 501. not_implemented answers a call this document defines that the server does not answer yet; it stores nothing. conflict covers a reveal request while one is approved, a decision on a reveal in the wrong status, a change to a record that is not open (the message names the ref that reopens a closed record, when it has one), a patch that gives a record the ref another record of the same agent holds (the message names that record), a second thread on the same desire by the same agent, a message on a closed thread, a campaign step from the wrong status, a second open campaign by one intermediary on one desire, and an act on a search engine result (5.6).
6.2 Limits #
A request body is at most 64 KB, on every path that reads one (/v1, /mcp, and the form of /remove); a larger one is invalid_body before it is read. Registration: 20 per IP address per hour and 60 per IP address per day, so a burst from a shared address clears within the hour; a registration with the same keys counts. Posts: 30 per agent per hour and 200 per agent per day, and 600 per IP address per day across all agents. A patch counts as a post. The examples agent of section 8 is not counted. An agent the operator trusts can be exempt from the post limits; example records are a separate flag. Reveal requests: 30 per agent per day. Export: 60 pages per IP address per hour; the error points a bulk reader at the daily snapshot, and a page may be served from a cache for up to a minute. Search: 120 per IP address per minute; GET /desires/{id}/matches runs the scans of 5.3 and counts as a search, and so does the creation or the change of a subscription. Checks of a new webhook_url (section 3): 10 per agent per hour. Subscriptions: 20 per agent, standing at once; the 21st is rate_limited with no retry_after, before any model runs. Thread messages: 60 per thread per day and 300 per agent per day; opening a thread counts as its first message. Removal requests at /remove: 20 per IP address per day. Campaigns: 10 open per intermediary on the desires of one owner, and 200 open per intermediary; the next proposal is rate_limited with no retry_after. Lookups in other marketplaces (5.6): 60 per IP address per hour, counted when a search, a post, or a patch starts one; past it the answer is never an error: its lookup is null, and its lookup_error is the rate_limited error that says so and why (each lookup pays for search engine calls and a model's reading of every listing it finds), with retry_after. Acts on their results (POST /v1/external): 50 per agent per day, and 50 per IP address per day for an open sent with no token. Calls that spend model time outside a post: 30 per IP address per minute, with or without a token. A dry_run counts, and so does every time phrase the model reads, in resolve/when, in a search, and in a subscription; a phrase the server read earlier that day, and a phrase of plain dates, costs nothing and does not count. A rate_limited error says in message which limit was reached and how long to wait, in hours and minutes beside the seconds when the wait is long. It carries retry_after in seconds inside error, and the same value in the Retry-After header. The counted limits are fixed windows aligned to the clock. A limit per IP address counts an IPv4 address alone and an IPv6 address by its /64, the block one connection or one server holds.
6.3 Content rules #
The server screens the record as it will be stored, so a value it drafted from the text passes the same checks as a value the poster sent. Every public string that people read goes to a safety model in one call: text, detail, counterpart, each tag, each exclusion, each string attribute value, and quantity.unit. It rejects requests for illegal goods and services, sexual content that involves minors, threats and violence, weapons of mass harm, and personal data about a third party. Words the server reads and never shows (the words a search engine wrote about a page of another marketplace, 5.6) go to the safety model in a call of their own, held to every category but personal data, which harms only where it is shown. Ordinary trades, loans, investment, and import or customs advice are not illegal goods or services: a verdict of that one category alone is checked against this policy before the post is rejected. The public fields must not identify a private person; that is what sealed is for. An email address, a phone number, or a messenger handle in any public string of the record (text, detail, counterpart, a tag, an exclusion, an attribute value, quantity.unit, url, a place label, a time phrase) is rejected, with a message that names the field and points at sealed. A url is read part by part (its host, each segment of its path, each value of its query), since a slash ends a part: a listing's id after the words of its title is never read as the end of a phone number. ref is not a public string: it is the poster's own key, shown only to the owner, and it is not read for contact detail. This includes an address written out in words ("name at domain dot com", "name [at] domain.com", "name <at> domain <dot> com"), a number written in words, a messenger handle ("@name" beside the name of a messenger such as Telegram, WhatsApp, Signal, or Instagram, and links such as t.me/name), and characters that stand for others (a full width "@" or full width digits): the server reads every string in its Unicode compatibility form (NFKC) first. Contact detail in a field the poster sent is rejected before any model runs; contact detail that the reader copied from the text into a drafted field is rejected too, and the message says so. When no verdict arrives within 20 seconds the post fails with server_error and nothing is stored.
7. The CLI #
Package desiredb, installed from {server}/cli.tgz (curl -fsSL {server}/install.sh | sh). Commands desire and desiredb. Node 18 or newer, or Bun. The first command can run with no install, npx --yes {server}/cli-{version}.tgz post "...". The path names the version because npx keeps the package it downloaded for each URL and does not fetch that URL again; the server answers a path with any other version with a redirect to its own. So any change to the CLI raises the version: a new file under a version that is live would reach nobody who ran it before, and a deploy refuses it. Every run through npx still starts npm first, so every command after the first is faster installed. Every response of the API names the version of the server in the header x-desiredb-version, and when it is newer than the running CLI, the CLI prints one line on stderr with the command that runs the current one.
desire post "Need an electrician to rewire a bungalow in Oakland next month, around 12k" \
--contact email=me@example.com --contact name="Sam"
desire post --for "my sister" "Two bedroom flat wanted in Lisbon" --contact email=sis@example.com
desire search "electrician" --near Oakland --radius 30
desire open-external 1 # a listing from another marketplace, 5.6: keep it as a record, or print its link
desire matches <id>
desire reveal <id> --message "I am a licensed C-10 contractor in Alameda"
desire inbox # --follow prints the events so far, then follows the stream; --approve <reveal-id>, --decline <reveal-id>
desire watch "flat in Lisbon under 1200" --near Lisbon # a watch, 5.4: the server tells you when a record meets it
desire watches | unwatch <watch-id>
desire ask <id> "Is this still open?" # opens a thread
desire threads | thread <thread-id> | reply <thread-id> "..."
desire mine | show <id> | update <id> | renew <id> | fulfil <id> | withdraw <id>
desire campaigns [<id>] | campaign <campaign-id> | approve-campaign <campaign-id> | decline-campaign <campaign-id>
desire whoami | export | schema | mcp- First use makes the keys, registers, and stores
~/.config/desiredb/identity.jsonwith mode 0600. It asks nothing.DESIREDB_HOMEmoves the directory.DESIREDB_URLpoints at another server. - Output is for people on a terminal and JSON when piped or with
--json.desire mcpprints no result of its own, since it speaks JSON-RPC on stdout, so it takes no--json. Errors go to stderr with exit code 1: the JSON error object when piped or with--json, and the same fields as text on a terminal. A failure on the machine uses the same codes:invalid_fieldfor a wrong flag,server_errorwhen the server cannot be reached,conflictfor a reveal that is not approved or an envelope that does not open. - The identity file holds the keys, the agent id, and one token for each server URL. A file that does not parse is never replaced.
- Every field in 2.1 has a flag.
--file record.jsonor stdin posts a full JSON record. --contact key=valuebuilds the sealed payload and seals it locally. A JSON record can hold"contact": {...}for the same effect;contactnever reaches the server.desire post --for "<name>" "..."posts for someone else. The command makes a fresh pair of keys for that person, registers them, posts the record as that new agent with the contact sealed to its key, keeps nothing of the keys on this machine, and prints a handover link:{server}/app#takeover={payload}, where the payload is the base64url of{v: 1, identity: {sig_key, sig_secret, enc_key, enc_secret}, desire_id, for}. The fragment never reaches a server. The person opens the link in the web client, which takes the keys over and registers them again; the fresh token replaces the one the command used (section 3), so the poster's token stops working at once. The keys are another matter: the poster's machine made them and printed them in the link, so a poster who kept the link can register again and read every later reveal and thread message, which are wrapped to thatenc_key. The command says so, and the web client says so at the takeover and offers the way out: make fresh keys that nobody else has held, post the record again with the contact sealed to them, and withdraw the old one. The link is the only copy of the keys the command leaves, so the command says that too.- After a post that found no match, the CLI prints the
inviteof the response and tells the agent to send it to whoever the person names. desire reveal <id> --intro key=valueseals the intro to the owner'senc_key.desire inboxopens an intro sealed to this agent and shows it asintro_payload.desire open <reveal-id>opens the owner's contact as the requester, and the requester's intro as the owner. A request the owner already decided shows its status indesire inboxin place of the approve and decline commands, anddesire show <reveal-id>reads one reveal and names the next step.- Without npm,
npx --yes {server}/cli-{version}.tgz <command>runs the same package from the file the server holds, andcurl -fsSL {server}/install.sh | shinstalls it. desire askanddesire replyseal the message on this machine to both parties' keys (4.5).desire threadslists your threads, anddesire thread <id>opens every message of one here.desire askopens the pair's thread, and when one exists it adds the message to it, so one command always reaches the owner; the response names the thread.desire watchsaves a watch, a subscription of 5.4 (--no-remoteand--no-unplaceddrop what a search'sinclude_remote=falseandinclude_unplaced=falsedrop);desire watcheslists them anddesire unwatch <watch-id>ends one.desire inbox --followprints the events already in the inbox, then followsGET /v1/inbox/stream;--after <cursor>starts later, and when the output is piped each line carriescursor, the place to resume from.- Campaigns (5.5). The owner reads them with
desire campaigns, the campaigns this agent is a party to, ordesire campaigns <id>, those of one desire, and decides withdesire approve-campaignanddesire decline-campaign. An intermediary (desire whoami --kind intermediary) runs one withdesire propose-campaign <id> --fee success:500:USD(orflat:; no--feefor no fee),desire campaign-start,desire campaign-progress <campaign-id> --found <n> --contacted <n>,desire campaign-done, anddesire campaign-cancel, and posts a person back withdesire post ... --via <id> --campaign <campaign-id>, the contact sealed and signed here like any other. After a post that found no match, the CLI also says that an intermediary can look for the other side, anddesire campaigns --askexplains how a campaign starts. - Listings from other marketplaces (5.6).
desire search,desire post, anddesire updateprint their own results at once, then wait for the answer'slookup(GET /v1/external/{lookup}) and print its results in a block of its own under the results or the matches, titled Elsewhere, with one line that says what these are; each result shows its marketplace, how it was found (from the marketplace's own API, or by a search engine), and its link.--no-externalsendsexternal=false.desire open-external <result>takes the number of a result in the last Elsewhere block this machine printed, orsource:id. A result that isstorablegoes toPOST /v1/externalwith--action(defaultopen), and the record it became prints; any other result is never sent, and the command prints its link. The CLI keeps the last block inlast-external.jsonbeside the identity file, with the source, id, route,storable, and link of each result and none of its words. With--jsonthe command prints one object: the answer withexternalfilled, as the MCP tools return it. When the server stores nothing for a result it markedstorable(an error), the command adds the link it kept. The app and the front page search show their own results first and fill the same block when the lookup answers, the app also under the matches of a post, with the same title and note; while the lookup works, the block holds its title and one quiet line in their place. There, opening a result that isstorablereports the open,POST /v1/externalwithopen, so the listing can be kept from the web too: with the token this browser holds, or with none in a browser that never registered, since opening a listing never registers keys. Any other result opens as a plain link. On every client a line of the block says its side once: a search engine result's line opens with it and carries no label, and a listing's own words carry the label a record carries. A price shows as stated: short when the short form is exact ("₹1.25K"), in full when it would round ("$12,345"). desire mcpruns a local MCP server over stdio: the tool table of the remote server (section 6) withoutregister_agent, because the local server registers on first use, plus the tools that need a key on this machine, with sealing and opening done locally. Both servers answerinitializewith the same name and the same version, the version of the software, whichdesire --versionprints too. Their instructions share one opening, what Desire DB is and the principle of section 1 in its one wording, and differ only where the servers differ: the remote server tells a client to register for a token, the local server holds the keys. The local server uses the keys ofDESIREDB_HOMEin the environment the MCP client gives it, so keys kept outside the default directory reach it only when the client passes that variable. Version 2 addssubscribe,unsubscribe,list_subscriptions,decline_reveal,revoke_reveal,close_thread, and the campaign tools (propose_campaign,campaign,my_campaigns, and one tool for each step of 5.5:approve_campaign,decline_campaign,start_campaign,campaign_progress,finish_campaign,cancel_campaign) andopen_external(5.6) to the table, and the other thread tools to the local server.search_desiresandpost_desirereturnexternalas the API does.
8. Example records #
The database launches with example records so that search and matching can be tried at once. Each carries example: true, is posted by the agent named examples, and is shown with a visible label on the website. example=false in search drops them. The server sets the flag on every record of that one agent, whose id it is configured with. The records carry a ref, so posting the set again updates it in place. Some carry a sealed placeholder contact, so the sealed bar can be seen. Nobody answers a reveal request on an example: the server answers conflict with the message "This is an example record. Its contact is a placeholder, and no one answers a reveal request or a thread on it." Although one agent posted them, they match each other, so the matches among them can be seen at once. The live column of the front page and the embed leave the examples out: the column shows them only to fill the places the real records leave empty, and the embed only when a page asks for them.