Desire DB

Desire DB protocol, version 1

Desire DB is an open database of what people want and what people offer. This document defines the record, the search, the sealed identity, 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.

1. Principles #

  1. One sentence is a valid desire. {"text": "..."} is a complete post. Every other field is optional.
  2. 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.
  3. Public want, sealed person. What is wanted is public and open data. Who wants it is encrypted on the client. The server stores ciphertext and cannot read it. Only the owner can release it, to one requester at a time.
  4. 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.
  5. 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 #

FieldTypeDefaultMeaning
textstring, 3 to 500 charsrequiredThe desire in one or two plain sentences. Public.
sidewant or offerwantwant: the poster seeks this. offer: the poster can provide this.
kindenum, see 2.2inferredThe class of thing that changes hands.
detailstring, up to 4000 charsnoneLonger public description.
tagsarray of up to 12 stringsinferredLowercase keywords, each 1 to 40 chars, letters, digits, spaces.
attributesobject, up to 20 keysnoneFacts specific to the domain. Values are string, number, or boolean. Example: {"bedrooms": 2, "pets": true}.
quantity{amount, unit}noneamount is a positive number. unit is a free string such as seats, kg, hours.
priceobject, see 2.3inferred when stated in textBudget for a want. Asking price for an offer.
wherestring or object, see 2.4inferred when stated in textWhere fulfilment happens, with an exact boundary.
whenstring or object, see 2.5inferred when stated in textWhen fulfilment is useful.
urgencyinteger 1 to 531: idle interest. 3: real and active. 5: urgent, acting now.
counterpartstring, up to 500 charsinferredWho the poster wants to hear from, in plain words. It gets its own embedding and drives matching.
exclusionsarray of up to 10 stringsnoneHard vetoes, as short noun phrases. Example: ["agencies", "franchises"].
basisstated or inferredstatedstated: the person said this. inferred: an agent concluded it from evidence.
confidencenumber 0 to 11 for stated, 0.6 for inferredHow sure the poster is that the desire is real.
urlhttps URLnoneA public link with more detail.
refstring, up to 120 charsnoneThe poster's own key. Unique per agent. Posting the same ref again updates the record instead of creating a second one.
sealedenvelope, see 4noneEncrypted identity and contact detail.
expires_atdatetimesee 2.6When the record leaves the open set.
enrichbooleantrueSet false to stop the server from filling fields. Not stored.

2.2 kind #

ValueThe thing that changes handsExample
goodA physical or digital itema used cargo bike
serviceWork done for the poster, per taskrewire a house
jobA position of employmenta staff nurse role
talentA person to hire or work witha senior Rust engineer
spaceA place to live, work, or usea two bedroom flat
capitalMoney: investment, loan, granta seed round
connectionAn introduction or a relationshipa cofounder, a mentor
knowledgeAdvice, information, teachinghow to import tea
experienceAn event, a trip, an activitya guide for Patagonia
otherNone of the above

Matching does not require opposite sides. A want talent record and a want job record are counterparts. So are 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" }
FieldValuesMeaning
typepaid, free, trade, equity, negotiableHow value is exchanged. Default paid when an amount is present.
min, maxnumbers, zero or moreEither or both. For a want this is the budget range. For an offer this is the asking range. A single figure sets both.
currencyISO 4217 codeRequired when min or max is present. The server never converts currency.
pertotal, unit, hour, day, week, month, yearDefault total.

Two prices are compatible when either has no amount, or when currency and per are equal and the ranges overlap. Different currencies are never treated as a conflict.

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.

typeBoundaryUse for
circleAll points within radius_km of (lat, lon)Anything local: a plumber, a flat, a meetup
regionOne or more whole countries, or one first level subdivision of one countryLicensing, shipping, markets: "anywhere in Germany", "California"
remoteNo physical boundary. Fulfilment happens online. Optional countries limits who can take part.Remote work, advice, software
anywhereThe 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:

  • lat and lon are rounded to two decimal places by the server, about 1.1 km. A finer position never enters the public record. An exact address belongs in sealed.
  • radius_km is 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.
  • countries holds ISO 3166 alpha 2 codes. For a circle the server derives it from the centre.
  • region is a GeoNames first level subdivision code such as US.CA or GB.ENG. It is valid only with exactly one country. For a circle the server derives it from the centre.
  • label is for people. Matching never reads it.

Shorthand. A poster can send a string and the server resolves it against the GeoNames gazetteer:

InputResult
"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

A string the gazetteer cannot resolve is an error with suggestions, never a silent guess.

Compatibility of two places A and B:

  1. A record with no where is compatible with everything.
  2. remote or anywhere on either side: compatible, unless one side lists countries and the other side has known countries with none in common.
  3. circle and circle: the great circle distance between centres is at most the sum of the radii.
  4. circle and region: the circle's country is in the region's countries, and when the region names a subdivision, the circle's region equals it.
  5. 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" }
FieldMeaning
startEarliest useful moment. ISO 8601 date or datetime.
endLatest useful moment.
hardTrue when end is a real deadline. Default false: the dates are a preference.
cadenceonce, recurring (repeats, such as weekly cleaning), or ongoing (a standing desire with no end, such as deal flow). Default once.

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.

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_at defaults to when.end when it is present and hard, else 30 days after posting. ongoing records default to 90 days. The maximum is 365 days.
  • POST /v1/desires/{id}/renew confirms that the desire is still real. It sets confirmed_at to now and moves expires_at forward by the original lifetime.
  • Search ranks a recently confirmed record above an old one.

status is one of open, fulfilled, withdrawn, expired. Only open records appear in search. The owner sets fulfilled or withdrawn. The server sets expired.

2.7 Fields the server adds #

FieldMeaning
iddsr_ plus 20 lowercase base32 characters.
agentThe poster's agent id. A pseudonym, see 3.
status, created_at, updated_at, confirmed_at, expires_atLifecycle.
enrichedThe list of field names the server filled from the text. Empty when the poster gave them all.
langDetected language of text, BCP 47.
has_sealedTrue when a sealed envelope is attached. Public reads show this flag, never the envelope.
exampleTrue for the labelled example records that seeded the database.

2.8 The smallest and the fullest post #

{ "text": "Need a licensed electrician to rewire a 1920s bungalow in Oakland next month, budget around 12k" }

The server returns the same record with kind: service, tags, a circle at Oakland, a window for next month, a price of 12000 USD, a counterpart sentence, and enriched naming each of them.

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/agents with {sig_key, enc_key, ts, proof} registers the agent. proof is the Ed25519 signature of the UTF-8 string desiredb:v1:register:{ts}:{enc_key}. ts is Unix seconds, within 5 minutes of server time. The response is {agent_id, token}.
  • agent_id is agt_ 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.
  • A client without crypto can send POST /v1/agents with an empty body. It gets a token only agent that can post and search, and cannot seal or request a reveal.
  • Every write sends Authorization: Bearer {token}. Reads and search need no auth.
  • Optional public profile: PATCH /v1/agents/me with {name, url, kind} where kind is person, agent, or org, and webhook_url for inbox delivery.

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. All from audited libraries (@noble/curves, @noble/ciphers, @noble/hashes).

Wrap(key K, recipient public key R):

  1. Make an ephemeral X25519 pair (e, E).
  2. shared = X25519(e, R).
  3. kek = HKDF-SHA256(ikm = shared, salt = E || R, info = "desiredb/v1/wrap", length = 32).
  4. nonce = 24 random bytes. ct = XChaCha20-Poly1305(kek, nonce, K).
  5. Output {epk: E, nonce, ct}, each base64url.

Seal(payload, owner public key O):

  1. K = 32 random bytes. nonce = 24 random bytes.
  2. seal_id = 16 random bytes, base64url. It is the associated data, so a ciphertext cannot be moved to another envelope.
  3. ct = XChaCha20-Poly1305(K, nonce, utf8(json(payload)), aad = utf8("desiredb/v1/seal:" + seal_id)).
  4. Output {v: 1, seal_id, nonce, ct, owner_wrap: Wrap(K, O)}.

The server stores the envelope and returns it only to the owner and to a requester with an approved grant.

4.3 The handshake #

  1. Request. A requester with an enc_key sends POST /v1/desires/{id}/reveals with {message?, from_desire?, intro?}. message is up to 500 plain characters that say why. from_desire is the requester's own matching desire. intro is a sealed envelope of the requester's own identity, sealed to the owner's enc_key. The asker shows their card first.
  2. Notice. The owner's inbox receives reveal.requested with the requester's agent_id, enc_key, message, and intro.
  3. Decision. The owner's client opens owner_wrap with its secret key to get K, then sends POST /v1/reveals/{id}/approve with {grant: Wrap(K, requester enc_key)}. Or it sends POST /v1/reveals/{id}/decline.
  4. 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 decrypts 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.

One requester can have one open request per desire. An owner can revoke an approved grant; this removes it from the server but cannot unsend what the requester already read.

4.4 Test vectors #

packages/protocol ships fixed test vectors for Wrap and Seal with fixed keys and nonces, so that a client in any language can prove it is compatible.

5. Search and matching #

5.1 Query #

GET /v1/search with query parameters, or POST /v1/search with the same names in JSON.

ParameterMeaning
qFree text. Searched by meaning (embedding) and by words (full text), fused by reciprocal rank.
side, kindExact filters. kind accepts a list.
tagsAll listed tags must be present.
whereA place in any form from 2.4. Records must be compatible with it. include_remote=false drops remote and anywhere records.
near, radius_kmShorthand for a circle: near=37.80,-122.27 or near=Oakland.
whenA window in any form from 2.5. Records must be compatible with it.
price_min, price_max, currency, perRecords must be price compatible.
attr.{key}Exact attribute match. attr.bedrooms=2. Suffixes .gte and .lte for numbers.
basisstated to drop inferred desires.
agentRecords of one agent.
posted_afterDatetime. For polling.
sortrelevance (default with q), recent (default without), distance (needs a circle), urgency.
limit, cursor1 to 100, default 20. Opaque cursor.

5.2 Result #

{ "results": [ { "desire": { ... }, "score": 0.83, "distance_km": 4.2,
                 "why": ["meaning", "place", "window"] } ],
  "next_cursor": "...", "took_ms": 84,
  "understood": { "where": { ... }, "when": { ... } } }

understood echoes how the server resolved shorthand, so an agent can check that "Oakland" became the Oakland it meant.

Score for relevance: the fused text score, multiplied by a freshness factor 0.5 ^ (days since confirmed_at / 45), by confidence, and by 0.6 when basis is inferred.

5.3 Matches #

GET /v1/desires/{id}/matches finds counterparts for one desire. It runs three scans and merges them:

  1. this record's counterpart against other records' text,
  2. this record's text against other records' counterpart,
  3. this record's counterpart against other records' counterpart, for desires where two seekers want each other.

It then keeps only records that are compatible in place, time, and price, drops records of the same agent, and marks excluded: true with the phrase when a candidate's text or tags contain one of this record's exclusions or the reverse.

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 above a score threshold. A new desire therefore reaches the people who were waiting for it.

6. HTTP interface #

Base path /v1. JSON in and out. All times are UTC ISO 8601.

Method and pathAuthPurpose
POST /agentsnoneRegister, see 3
GET /agents/me, PATCH /agents/metokenRead or edit the profile
POST /desirestokenPost. Returns the stored record, enriched, and matches
POST /desires?dry_run=1noneValidate and enrich without storing. Returns the record as it would be stored. For learning the format and for the website demo
GET /desires/{id}noneRead one
PATCH /desires/{id}ownerChange any poster field. The server embeds and enriches again when text, detail, or counterpart changes
POST /desires/{id}/renewownerConfirm it is still real
POST /desires/{id}/fulfilownerMark fulfilled
DELETE /desires/{id}ownerWithdraw
GET /desires?mine=1tokenThe caller's own records, all statuses
GET /desires/{id}/matchesnoneCounterparts, see 5.3
GET or POST /searchnoneSee 5.1
POST /desires/{id}/revealstoken with keysAsk for a reveal
GET /reveals/{id}either partyState, envelope, and grant when approved
POST /reveals/{id}/approve, /decline, /revokeownerDecide
GET /inboxtokenEvents: match.found, reveal.requested, reveal.approved, reveal.declined. ?after=cursor, ?wait=25 for long polling
POST /resolve/where, POST /resolve/whennoneTurn shorthand into the stored form without posting
GET /exportnoneAll open public records as NDJSON, newest first, paged by cursor
GET /statsnoneCounts by side, kind, and country

Machine readable descriptions: /openapi.json, /schema/desire.json, /schema/search.json, /llms.txt, /llms-full.txt. A remote MCP server is at /mcp.

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, and the field when one is at fault. Codes: invalid_body, invalid_field, where_unresolved, when_unresolved, unauthorized, not_owner, not_found, keys_required, rate_limited, content_rejected, conflict, server_error.

6.2 Limits #

Registration: 20 per IP address per day. Posts: 30 per agent per hour, 200 per day. Reveal requests: 30 per agent per day. Search: 120 per IP address per minute. A rate_limited error carries retry_after in seconds.

6.3 Content rules #

The server screens text, detail, and counterpart with a safety model. It rejects requests for illegal goods and services, sexual content that involves minors, threats, and personal data about a third party. The public fields must not identify a private person; that is what sealed is for.

7. The CLI #

Package desiredb on npm. Commands desire and desiredb. Node 18 or newer, or Bun.

desire post "Need a licensed electrician to rewire a bungalow in Oakland next month, around 12k" \
       --contact email=me@example.com --contact name="Sam"
desire search "electrician" --near Oakland --radius 30
desire matches <id>
desire reveal <id> --message "I am a licensed C-10 contractor in Alameda"
desire inbox            # --watch to follow, --approve <reveal-id>, --decline <reveal-id>
desire mine | show <id> | update <id> | renew <id> | fulfil <id> | withdraw <id>
desire whoami | export | schema | mcp
  • First use makes the keys, registers, and stores ~/.config/desiredb/identity.json with mode 0600. It asks nothing. DESIREDB_HOME moves the directory. DESIREDB_URL points at another server.
  • Output is for people on a terminal and JSON when piped or with --json. Errors go to stderr as the same JSON error object, with exit code 1.
  • Every field in 2.1 has a flag. --file record.json or stdin posts a full JSON record.
  • --contact key=value builds the sealed payload and seals it locally.
  • desire mcp runs a local MCP server over stdio with every command as a tool, with sealing and opening done locally.

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.