# PrefSift API reference

JSON over HTTPS. Every endpoint, field and error. New here? Start with the [quickstart](quickstart.md). Agents: read the [skill file](skill.md).

**Base URL.** The URL that came with your API key (below: `$PREFSIFT_URL`).

**Modes.** How the sides get in:

| Mode | Who sends what | Call |
|---|---|---|
| A | One caller already has every side (your app holds both, or the organizer collected them with consent) | `POST /v1/decide` |
| B | One caller; the other side's agent doesn't use PrefSift but answers questions you relay | `POST /v1/decide`, again with the answers |
| C | A neutral host (a marketplace, an app) submits each side for it | a session with `"relay": true` |
| D | Each side submits its own limits with its own code | a session |

**Words used below.** An option is *crossed out* when it breaks a party's hard limit, *beaten* when another option is at least as good for every party and better for at least one, and *kept* when neither. Exact definitions: [semantics](semantics.md).

## Errors

Every error is JSON: `{"detail": "<message>"}`, or for an invalid body `{"detail": [{"loc": [...], "msg": "...", "type": "..."}]}`.

| Code | Meaning |
|---|---|
| `401` | No API key where one is needed, an invalid key, or no session code |
| `402` | The API key that pays for this reached its monthly spend cap |
| `403` | Wrong session or approval code, or not permitted (submitting another side without relay, answering someone else's approval) |
| `404` | Unknown session, party, decision or approval |
| `409` | Session ended, still waiting before the deadline, a declined side, or an approval already answered |
| `422` | Invalid input; the message says what |
| `503` | The reader or search is unavailable (never a disguised success) |

## Authentication and spend caps

`POST /v1/decide`, `/v1/compile`, `/v1/solve`, `/v1/revise` and `POST /v1/sessions` need `Authorization: Bearer psk_...` (or `X-API-Key`). Without a key: `401`. Each key has a monthly cap in dollars; once the month's reading spend reaches it, calls that would read more get `402` until the month turns or the cap is raised. Invited parties never need a key: they act with their session code, and reading their side is billed to the key that created the session (so a submission is refused with `402` when that key is over its cap or revoked). Only a SHA-256 of each key is stored. A missing key and an invalid key both return `401`, with different messages. Ask us to raise or lower a key's cap.

## One call: `POST /v1/decide` (modes A and B)

| Endpoint | Body | Returns |
|---|---|---|
| `POST /v1/compile` | `RawRequest` (or the legacy `type/prefs/candidates` shape) | `content_hash`, `version`, `problem` (CompiledProblem), `diagnostics`, `usage` (actual calls and tokens, `tokens_available`), `provenance` (rule id → evidence ids), `timing_ms`, `read_coverage`, `billable`, and `options` (only when the request set `options`) |
| `POST /v1/solve` | `{problem}` or `{content_hash}` | `DecisionResult` |
| `POST /v1/decide` | `RawRequest`, plus optional `viewer` and `visibility` | `{compile, result, explanation}` with costs reported separately |
| `POST /v1/revise` | `{content_hash | problem, revision}` | new `content_hash`, `version`, `problem`, `invalidated` cache keys |
| `GET /v1/problems/{content_hash}` | | the stored compiled problem |
| `GET /v1/health` | | schema and semantics versions, and `auth` (`api_key` or `none`) |
| `GET /v1/approvals/{decision_id}` | an approver (header `X-Approval-Code`) | the decision as it stands: `approvals`, `rejected_options`, `result`, `explanation` |
| `POST /v1/approvals/{decision_id}/{approval_id}` | the named approver | `{decision: "approve" \| "reject", note?}`; a reject removes that option and decides again |

Without `viewer`/`visibility` the explanation names every party's limits. `explanation.questions_detail` holds structured questions (below), `explanation.selection` one line per declared selection step, and `compile.options` the option sources. With `viewer` (a party id, or `{participant_id, role, party_id}` with role `initiator | party | host | observer`) and an optional `visibility` table, the explanation is rendered for that viewer; the compiled problem and result stay whole, because the caller sent every side.

`explanation` (built from the proofs; no model call): `summary`, `options` (one sentence per option), `questions` (up to three strings, as before), `questions_detail`, and `selection`. Each `questions_detail` item: `question_id`, `kind` (`fact` | `preference` | `limits`), `text`, `ask` (who should answer: a party id, the participant who added the option, or null for "whoever knows this option's facts"), `about` (whose limit or preference depends on it), `attribute_ids`, `candidate_ids`, `answer_with` (the evidence or fact shape to send back). Mode B: ask the other side's agent the `text`, then call again with the answer as evidence on that party with `said_by: "counterparty"` and `authority: "third_party_claim"`.

## Sessions (modes C and D)

A session is one decision with its own participants, permissions, options and result. Every session call except `docs` authenticates with the caller's code: header `X-Session-Code: <code>` or `?code=`. Codes are returned once, at creation; only their SHA-256 is stored.

| Endpoint | Who | Body | Returns |
|---|---|---|---|
| `POST /v1/sessions` | anyone (becomes the initiator) | `CreateSession` | `session_id`, `codes` (participant id → code), `invitations` (per invited participant: code, docs URL, a three-sentence invitation readable with no web access), `participant_docs_url`, `search` (PrefSift search report), `search_requests` |
| `GET /v1/sessions/{id}` | any participant | | status, `waiting_for`, participants and their states |
| `POST /v1/sessions/{id}/join` | invited participant | | `you`, what to call next |
| `POST /v1/sessions/{id}/decline` | any participant | `{reason?}` | status; a required party declining ends the session; the initiator declining closes it |
| `PUT /v1/sessions/{id}/parties/{party_id}` | that party, or the host (or an initiator with no side) when `relay` is on | `{label?, evidence: [RawEvidence]}` | what was read from this side (`compiled.rules`, `unsupported`), `billed`, `waiting_for`, status |
| `POST /v1/sessions/{id}/candidates` | whoever `options.who_can_add` allows | `{candidates: [RawCandidate]}` | `added`, `updated` (an option re-sent by the participant who added it is replaced), `merged` (duplicates) |
| `POST /v1/sessions/{id}/search-results` | same, when `harness_search` is on | `{search_request_id, candidates}` | as above; found options are marked `discovered` |
| `POST /v1/sessions/{id}/decide` | initiator | | its view; before the deadline only when every required side is in |
| `GET /v1/sessions/{id}/result` | any participant | | this participant's view (below) |
| `GET /v1/sessions/{id}/record` | per `visibility.record` and grants | | the audit record, rendered for the viewer |
| `GET /v1/sessions/{id}/docs` | anyone with the session id (a code personalizes it) | | markdown participant docs rendered from the session's permission table |
| `POST /v1/sessions/{id}/approvals/{approval_id}` | the named approver | `{decision: "approve" \| "reject", note?}` | the approver's view; see Approvals |
| `POST /v1/sessions/{id}/next` | initiator | `{deadline?, context?, candidates?}` | a new session from this one as a template (same participants, permissions, rules, options; new codes); this session's outcome joins the history |

**CreateSession**: `context`, `kind` (`plan | split | deal | unknown`, declared by the caller; nothing reads it but the record and docs), `relationship`, `attributes`, `candidates`, `initiator` (`participant_id`, `label`, `party` (its own side, submitted at creation) or `party_id` (a side it will submit later), `redacted_as`), `parties` (invited: `party_id`, `label`, `required` (default true), `redacted_as` (how redacted reasons name it: "the buyer"), `pays_own_reading`), `host`, `observers`, `approvers` and `approvals` (sign-off rules, above), `webhook`, `relay` (mode C), `required_parties`, `visibility`, `options`, `memory_gate` (default `auto`), `deadline` (ISO 8601 with offset), `payer` (default the initiator), `selection_rules` (shorthand for `decision.selection_policy = declared_rules`), `outcome_rules` (constraints every option must meet, declared by the initiator, applied to everyone and shown to every participant: a venue’s house rules, a platform policy), `decision`, `history`, `prior_session_id` + `prior_session_code`.

**Lifecycle.** Each submission is compiled when it arrives against a per-session cache, so an unchanged side is never read twice. The session solves when every required party has submitted (or when the initiator calls `decide` after the deadline, without the missing sides, which is recorded) and re-decides on every later submission or new option. Relaying is refused when the initiator is itself a party and no neutral host is named: the other side's private limits must then go in directly (mode D).

**A participant's view** (`GET /result`): `status`, `you`, `waiting_for`, `deadline`, `declared_rules` (in words), `options` (the ones this viewer may see, with facts, `origin`, `added_by`, `source_refs`), `coverage`, `limits` (the rules this viewer may see; always its own), `outcome_rules`, `raw_inputs` (the evidence this viewer may see), `result` (`status`, `known_nondominated`, `selected_candidate_id` (null and `picked_another_option: true` when the pick is an option this viewer may not see), `tie`, `remaining`, `feasibility`, `candidate_scope`, `content_hash`, `timing`), `explanation` (rendered for this viewer; each question carries `for_you`), `search_requests` (when this viewer may add options), `billing` (the payer sees the ledger; everyone else `you_pay_usd`), `links`.

**The record** (`GET /record`): participants and their states (never codes), the permission table, option sources, declared rules and selection policy, history, deadline, payer, every event (created, joined, submitted with a SHA-256 of the side, resubmitted, added options, searched, declined, decided, redecided, decided after deadline), every option the viewer may see, the result and explanation rendered for the viewer, and the limits and raw inputs it may see. The full compiled problem and result are included only for a viewer allowed to see every side's limits and raw inputs (an observer with grants). Being allowed the record never reveals a party's private limits.

**Concurrency.** Calls on one session are serialized; a write against a changed session is refused rather than lost.

## Permissions (`visibility`)

```json
"visibility": {
  "limits": "own_only",          // own_only | named (via grants) | all: who sees a party's compiled limits
  "reasons": "redact_others",    // redact_others | full: reasons naming another party's limit
  "redaction": "party",          // party: "doesn't fit the buyer" | anonymous: "price $55 is over someone's limit"
  "raw_inputs": "own_only",      // own_only | all | a list of roles or participant ids ("host", "observers")
  "result": ["initiator", "parties", "host", "observers"],
  "record": ["initiator", "observers"],
  "options": "all",              // all | own: each participant sees the options it added (initiator, host, observers see all)
  "grants": [{"to": "finance", "see": ["limits", "reasons", "raw_inputs", "record", "options"], "of": null}]
}
```

Roles: `initiator`, `parties`, `host`, `observers`, or a participant id. A party always sees its own limits ("$420 is over your $400 limit"). An evidence item's own `visibility` wins over the table: `private` (that party only, however the table is set: "don't let anyone see my budget"), `named` with `visible_to`, `all_parties`, `mediator_only` (host and observers), or `party` (the default: follow the table). A redacted reason never names the hidden limit; a dominance reason names a hidden side but not why; options a viewer may not see are never named, even in questions ("Beaten by another option").

## Options (`options`)

```json
"options": {
  "sources": ["supplied", "corpus", "prefsift_search", "harness_search"],
  "search": {"query_hint": "dinner Eastside Friday 8:30pm", "max_results": 10, "cost_cap_usd": 0.10},
  "who_can_add": ["initiator", "parties"],
  "dedupe": "by_identity"
}
```

- `supplied`: `candidates` in the request, and (sessions) `POST /candidates`.
- `corpus`: options read from documents the parties sent: `system_record` items, or any item with `"contains_options": true`. One model call per document (`gpt-5-mini`, task `corpus`); every option must be named in the document and every fact carries a verbatim quote, held to it by the compiler's quoted-fact checks. Found options are `origin: discovered` with the evidence id in `source_refs`; in a session they belong to the party that sent the document (so a fact question about one is asked of that party).
- `prefsift_search`: PrefSift's own web search: the spend cap is checked first (each search about $0.01 plus tokens), and every found option must carry the URL of a page opened in the search and every fact its words. A session searches once, at creation; `/v1/decide` searches per call. Spend is billed to the payer at cost (`billable.search_usd`).
- `harness_search`: PrefSift returns `search_requests` (query, `max_results`, the attributes to `extract`, the shape to `return_as`, where to `post_to`); the calling agent searches with its own tools and sends the results back (`POST /search-results` in a session; `candidates` on the next `/v1/decide`).
- `dedupe: by_identity`: the same id, the same source URL with the same meaning, or the same meaning with the same stated facts is one option (its sources are merged); an id clash with different content is renamed. `none` only renames clashes.
- `coverage.candidate_scope` becomes `discovered_nonexhaustive` when any option was found rather than supplied (unless the request declares `coverage`): "best of what was found", not "best that exists".

Without an `options` block the request behaves exactly as before (supplied candidates plus the older `web_search_*` flags).

## Inputs

Evidence kinds: `instruction`, `principal_quote`, `agent_summary`, `inferred_memory` (with `matter_relevance`), `memory_file`, `system_record`, `structured`; `explicit` is still accepted. Optional `said_by` (`principal | operator | system | agent | counterparty | unknown`), `source`, `authority`, `visibility`, `visible_to`, `contains_options`.

`memory_file` is a raw personalization file (a whole memory export) that PrefSift sorts itself: chunked, every chunk through the relevance gate however small, rules from lines about this matter act, rules from other-occasion or unplaced lines are hypotheses, and lines about nothing in the decision are skipped (`read_coverage` reports how many).

`memory_gate`: `auto` (sessions' default): a `memory_file` is gated; an agent-curated `inferred_memory` acts as the party's position unless the agent labelled it `prior_matter`. `on`: a curated memory acts only when labelled `this_matter`. `off`: nothing is gated (a `prior_matter` label still holds). Unset on `/v1/decide`: the original behaviour (curated memories as under `on`; memory files gated).

Structured evidence may carry `named_preferences` (`{name, predicate, label}`): outcomes a party hopes for, read only by declared selection rules; they never cross anything out.

Facts stated in evidence (structured, or read from text) fill a cell that is missing or explicitly unknown; that is how the answer to a "Can't tell" question lands.

## Declared selection rules

`decision.selection_policy = {"kind": "declared_rules", "steps": [...]}` (or `selection_rules` on a session). Applied in order to the known-nondominated set; each step keeps the candidates best under it; the selection records every step (`considered`, `kept`, `set_aside`, the values compared). A pick is made only when one candidate is left; a tie that survives every step returns the tied set (end with `stable_id` to always pick). Unknown facts never help a candidate.

| step | fields | keeps |
|---|---|---|
| `minimize_max` | `measure` or `attributes` | smallest largest per-party value (lightest heaviest load) |
| `maximize_min` | `measure` or `attributes` | largest smallest per-party value (best-off worst-off) |
| `equalize` | `measure` or `attributes` | smallest spread (largest minus smallest) |
| `fewest_violations` | `name`, inline `preferences` | fewest unmet named preferences (unknowns count after definite misses) |
| `priority_by_history` | `name` or `measure` | the candidate whose losers include the fewest parties that lost in the most recent `decision.history` entry, then the most that won |
| `fewest_changes` | `attributes` | fewest attributes changed from the last history entry's `selected_facts` |
| `prefer` | `attribute_id`, `direction` or `order` | the best value on one attribute ("if several fit, pick the lowest deposit") |
| `party_priority` | `party_id`, `keys` | one party's declared lexicographic order |
| `stable_id` | | the smallest id |

Every step takes an optional `label` (how it reads in the docs and explanation) and `declared_by`. **Per-party quantities** are ordinary attributes tagged `belongs_to: <party_id>` and `measure: <name>` (`hours_kai`, `hours_rosa`, `hours_tom`, all `measure: "hours"`); the tags change nothing in feasibility or dominance and are omitted from dumps when unset, so existing requests and hashes are unchanged. **History** (`decision.history`, oldest first): `{ref, at, lost, won, selected_facts}`; a session fills it from `prior_session_id` or `next`, recording who lost and won (by named preferences, else the largest share of the first measure) and the picked facts.

## Approvals (sign-off before a result goes out)

A side can require a named person's sign-off before a result goes out. An approval rule: `rule_id`, `party_id` (whose limits it watches), `approver`, `when` (`near_limit`, the default, or `always`), the margin as `within_pct` (percent of the limit; default 5) and/or `within` (absolute, in the attribute's units: dollars for money), optional `attribute_ids`, and `label` (plain words for the record). Near a limit means one of that party's own numeric limits (at most / under / at least / over, on an integer, decimal, money or duration attribute) holds for the option and the option is within the margin of it; a limit whose condition does not hold for the option is ignored. Rules are checked against the option that would go out (the selected one; with no selection, each kept option).

On `POST /v1/decide`, send `approvals: [rule]` (and optionally `webhook`). The response gains `approval`: `{"status": "not_needed"}`, or `{"status": "pending", "decision_id", "requests", "approver_codes": {approver: code}, "answer_with"}`. Each request: `approval_id`, `rule_id`, `approver`, `party_id`, `candidate_id`, `status`, `reason` (plain words, e.g. "Trim A: $31,800 is 0.95% from the limit (at least $31,500)."), `near` (attribute, limit, value, gap). Give each approver its code; it answers with `POST /v1/approvals/{decision_id}/{approval_id}`. A reject removes that option and decides again, which can raise a new request.

In a session, list the people in `approvers` (`participant_id`, `label`; each gets a code and an invitation) and the rules in `approvals`. When a decision matches a rule, the session's status is `awaiting_approval`: the approver and the side whose limits it is about see the result and `approval.requests`; everyone else sees `result: null` and `approval: {"status": "pending", "note": ...}`, never why. The approver answers with `POST /v1/sessions/{id}/approvals/{approval_id}`. Approving the last pending request sets the status to `decided` and shows the result to everyone (`approval.status: "signed_off"`). Rejecting removes the option and decides again. A resubmission while a request is pending supersedes it. The record lists every request; viewers who may not see that side's limits get it without the reason. The participant docs tell every side that sign-off may happen.

## Webhooks

A session (`webhook`) or a `/v1/decide` call with approvals (`webhook`) may name a URL: `{"url": "https://...", "events": [...]}` (default: every event). Events: `joined`, `declined`, `submitted`, `resubmitted`, `decided`, `redecided`, `awaiting_approval`, `approved`, `rejected`, `ended`. PrefSift POSTs `{"event", "session_id" or "decision_id", "status", "at", "actor"}`: never anyone's limits, text or options; fetch your view with your code. The signing secret is returned once at creation (`webhook_secret`). Each request carries `PrefSift-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">`; check it, and reject times more than five minutes old. URLs must be https and must not resolve to a private or local address. Delivery is best effort with one retry.

## Predicates

As in `docs/semantics.md` §2, plus the **weekday** literal: `{"weekday": ["tue", "wed", "thu"], "tz": "America/New_York"}` with `in` / `not_in` (or `eq` / `ne`) on a timestamp or interval attribute; days as names or ISO numbers (1 = Monday); `tz` defaults to the attribute's `timezone`. The text compiler writes it for "only Tuesday to Thursday" and fills the zone from the options' offset.

## Billing

Token pricing: reading is billed at $0.50 per million tokens read and $8 per million written; solving is free. In a session the payer (default the initiator) pays for reading every side; a party with `pays_own_reading` pays for its own; everyone else pays nothing. PrefSift search spend is billed to the payer at cost. Each charge is an entry in the session's billing ledger, visible to the payer.

## Request and result shapes

- **Who writes the request.** PrefSift sits between agents. Every evidence item is authored by one side's agent and says what it carries:
  - `instruction`: a rule from the agent's operator or owner (system prompt, policy, configuration).
  - `principal_quote`: a short verbatim snippet of what the agent's person said.
  - `agent_summary`: the agent's own summary of what its person wants. It is acted on like the person's position, and rules from it carry basis `summarized` so the record shows they were paraphrased.
  - `inferred_memory`: something the agent remembers. Under the memory gate it passes the relevance gate (`matter_relevance`).
  - `memory_file`: a raw memory file PrefSift sorts itself, line by line.
  - `system_record`: text the agent read from a system (price list, CRM, calendar). `structured`: rules, facts and named preferences as data.
  - `explicit` is the older name for a statement on the person's behalf and is still accepted.
  Optional `said_by` (`principal | operator | system | agent | counterparty | unknown`) and `source` ("group chat, Oct 8", "rate card v3") are shown to the reader and kept in the record.
- **RawRequest**: `parties[].evidence[]`, `attributes`, `candidates`, `decision`, `coverage`, `options`, `memory_gate`, `kind`. Discovery flags default to false; when false no search call is made.
- **CompiledProblem**: attribute registry (with optional `belongs_to` / `measure`), candidate table with cells (`known | unknown | conflicted | not_applicable`), evidence store (with `visibility`), per-party rule sets and named preferences, structural rules, decision config (`inference_policy`, `selection_policy`, `outside_option_id`, `path_semantics`, `budget`, `history`), coverage. Its `content_hash` ignores `version` and `diagnostics`.
- **DecisionResult**: `status` (`set_returned | proposal_selected | needs_candidates | no_known_feasible_candidate | inconsistent_model | budget_exhausted`), feasibility partition with witnesses, `known_nondominated`, selection (always labelled procedural; with declared rules, `steps` and `remaining`), dominance witnesses with per-party proofs, acceptance states, party model status, unresolved pairs with reasons, completeness flags, counters, timing, `kernel_usage`, evidence requests, and the proof store.
- **Attributes**: `attribute_id`, `type` (`boolean | categorical | integer | decimal | money | duration | timestamp | interval | set`), `label`, `unit`, `currency`, `domain` (categorical values), `timezone`, and optional `description`, `price_basis`, `price_statistic`, `is_primary_price`, `applies_to`, `role`, `belongs_to`, `measure`. An attribute no type fits is reported in an `attribute_unsupported` diagnostic.
- **Facts** may be `{"value": ..., "quote": "..."}` (also inside `claims`), in candidates, structured evidence and offline fixtures; the quote is checked offline and a failing fact leaves the cell `unknown` with a diagnostic code (`quote_contradicts_value`, `price_basis_mismatch`, `price_lower_bound`, `price_range`, `not_a_price`, `price_foreign_currency`, `price_unevidenced_zero`, `price_free_quote_nonzero`). `price_basis_mixed` warns when a money attribute without a declared basis has quotes on different bases.
- **Money** in the compiled representation is integer minor units; strings like `"$15.00"` are accepted in rule literals and raw facts and scaled once.

## Revisions

`Revision{base_version, ops[]}` with `upsert_rule` (the old rule is kept as `superseded`), `supersede_rule`, `set_rule_status`, `set_fact`, `add_candidate`, `remove_candidate`, `add_evidence`, `set_decision`. A mismatched base version is rejected. The new version's content hash differs, so no cached result or comparison can be served for it; an unchanged party's comparison cache stays valid because it is keyed by that party's model hash and the domain hash.

