# Searching IPTO — recipes for agents

Every example is a complete call and what comes back. If you only read one thing:
**search returns the text**, so most questions are answered without ever
downloading a file.

Base URL `https://api.ipto.ai`. Authenticate with `x-api-key: ipto_…`, or
`Authorization: Bearer ipto_…`. A key is required — every call below answers
`401` without one. Your key sees your own tenant's content plus every **listed**
asset in the marketplace.

## What it costs

| Action | Billed |
|---|---|
| `GET /search` | one metered search against your plan's monthly allowance |
| Reading `snippet` / `text` from the results | nothing — it is already in the response |
| `GET /asset/{id}` | nothing |
| `POST /download` | prepaid credits, per download, by bytes × modality |

The cheapest useful habit: read the snippets, and download only when you need the
media itself.

Downloads are **prepaid** from a credit balance denominated in cents, and charged
**per download** — there is no perpetual licence, so fetching the same asset a
second time costs a second time. Price scales with bytes *and* modality: a
gigabyte of text costs 50× a gigabyte of video, because text is worth more per
byte. `POST /download` returns `estimated_credits` and `available_credits` before
you spend anything, and a short balance comes back as `402` with a body naming
what it needed.

## Who can see what

An asset is one of three things, set by its owner:

| `listing` | In search results | Retrievable by id |
|---|---|---|
| `private` | no | owner only |
| `unlisted` | no | yes, if you know the id |
| `listed` | yes | yes |

Separately, an owner can withdraw the **bytes** from sale while leaving the
passages readable — a preview listing. `GET /asset/{id}` still returns its text;
`POST /download` refuses. A private asset is `404`, identical to one that does not
exist, so the catalogue cannot be enumerated by probing.

## Answer a question from a private corpus

```
GET /search?q=what%20did%20we%20agree%20about%20refunds&mode=hybrid&limit=5
```

```json
{ "search_id": "srch_…", "total": 3, "results": [
  { "asset_id": "9f2c…", "snippet": "…refunds are issued within 14 days…",
    "modality": "audio", "kind": "transcript",
    "time_start_ms": 812000, "time_end_ms": 819500, "score": 8.41 }
]}
```

`snippet` is the answer. `asset_id` is the citation. Nothing else is needed.

`mode=semantic` returns only passages that are actually close to your query. A vector
index will always name its nearest neighbours, so without a cutoff every query — even
nonsense — came back with the whole corpus and `total` meant nothing. Results now have
to clear both an absolute similarity floor and a floor relative to the best hit, so a
query that matches nothing returns nothing. `IPTO_SEMANTIC_MIN_SCORE` and
`IPTO_SEMANTIC_REL_FLOOR` tune it.

`mode=hybrid` blends keyword and semantic ranking and is the default when
semantic search is enabled. Use `mode=keyword` when you need exact terms to
match exactly, `mode=semantic` when the wording in the question is unlikely to be
the wording in the corpus.

## Find the moment something was said, and fetch only that

This is the single biggest cost lever in the API.

```
GET /search?q=%22termination%20clause%22&modality=video
```

A hit carries `time_start_ms` / `time_end_ms`. Feed those straight back:

```
POST /download
{ "asset_id": "9f2c…", "start_ms": 812000, "end_ms": 819500 }
```

```json
{ "url": "/dl/dl_…", "expires_in": 300, "one_time": true, "kind": "segment" }
```

You are billed for the clip, not the file. Omit `start_ms`/`end_ms` and you get
the whole asset and are charged for all of it. Ranges apply to audio and video
only.

The URL needs no key, works once, and expires in five minutes. A second `GET`
returns `410 Gone` — mint a new one rather than caching the link.

## Ask for only one kind of content

`kind` is how a passage was produced, and it is usually what you actually mean:

| `kind` | Comes from |
|---|---|
| `transcript` | speech |
| `caption` | what a video frame or image looks like |
| `ocr` | text visible inside an image |
| `doc_chunk` | a page or section of a document |
| `user_text` | a human's own description, tags or correction |
| `metadata` | embedded tags — title, artist, album |

```
GET /search?q=quarterly%20targets&kind=transcript
```
finds what was *said*, not what happened to be on a slide behind the speaker.

Combine with `modality` (`audio` `video` `image` `document` `text`) to narrow
further, and `lang=en` for a language. Both prune which shards are scanned, so
they make the search cheaper as well as tighter.

## Scope to a period, or to what changed recently

```
GET /search?q=incident&after=1735689600&before=1738368000&sort=recent
```

`after` / `before` are unix seconds on the passage's creation. `sort=recent`
orders by time instead of relevance — the right choice for "what happened
lately", and much cheaper than paging deep into a relevance-ordered result set.

`source=gdrive` restricts to connector-ingested content, `source=upload` to
direct uploads.

## The query language

**There are two grammars, and which one you get depends on whether your query
contains `*` or `~`.** This is the one thing about the API that will surprise
you, so it is worth reading once.

### Grammar 1 — no `*` and no `~`

The full parser:

Words you do not join are **AND**ed: `traditional japanese clothing` means all three,
not any of them. That is deliberate — with OR a three-word query matched most of the
corpus, its ranking was term-frequency noise, and `mode=hybrid` fused that noise with
the vector ranking and lost to it.

| Syntax | Meaning |
|---|---|
| `a b` | both (implicit AND) |
| `a AND b`, `a OR b`, `a NOT b` | booleans |
| `+must`, `-exclude` | require or forbid a term |
| `(a OR b) AND c` | grouping |
| `"exact phrase"` | adjacent words, in order |
| `field:value` | restrict a clause to one field |

Fields you can name: `kind`, `lang`, `modality`, `source`, `title`, `asset_id`,
`visibility`, and the numeric locators `page`, `created_at`, `t_start`, `t_end`.
Matches in `title` are boosted, so a document's own name outranks a passing
mention in its body.

```
GET /search?q=%22master+services+agreement%22+AND+renewal+-draft
GET /search?q=budget+AND+kind%3Atranscript+AND+lang%3Aen
```

### Grammar 2 — the query contains `*` or `~`

A single wildcard or fuzzy token switches the whole query to a term grammar over
the text fields:

| Syntax | Meaning |
|---|---|
| `term*` | prefix |
| `term~` / `term~2` | fuzzy, edit distance 1 or 2 (clamped) |
| `"exact phrase"` | still works |
| `+term`, `-term` | require or forbid, per term |

**In this grammar `AND`, `OR` and `NOT` are ordinary words, not operators, and
`field:value` is not a clause.** So this does *not* do what it looks like:

```
GET /search?q=revenu*+NOT+guidance      # NOT is a search term here, not negation
```

Write it with the structured parameters instead, which work in **both**
grammars and are the reliable way to narrow any search:

```
GET /search?q=revenu*&kind=transcript&lang=en
```

That is the rule of thumb: **put the words in `q`, put the constraints in
parameters.** Then it does not matter which grammar you are in.

### Typo tolerance

A query that returns **zero** results is retried, unless
it contains syntax by which you asked for exactness: `:`, `"`, `()`, `[]`, `^`,
`*`, `?`, `~`, `+`, or a `-term` exclusion. A query you wrote deliberately is run
exactly as written, because guessing at precise syntax would be worse than
returning nothing.

So `revenue guidnce` finds things, and `+revenue +guidnce` does not. That is on
purpose.

**The retry widens in two steps, and the first one is the useful one.** First every
term may grow to the RIGHT — `india` reaches `indian`, `analys` reaches `analysis`.
Only if that still finds nothing may terms be MISSPELT — `guidnce` reaches
`guidance`. The order matters more than it sounds: edit distance on a stemmed index
is very loose for short words, and `india` is one substitution from `indic`, the stem
of "indicating". Asking for prefixes first means the common case (a word you typed in
its base form) resolves precisely, and the loose rule is only ever reached when the
alternative is no results at all.

**`AND`, `OR` and `NOT` do NOT opt you out.** They say how to combine terms, not
that each term must match exactly, so a boolean query that finds nothing is
retried the same way — with the operators preserved, so `a OR b` stays an OR, and
through the same two steps. This
changed: they used to opt out, which made writing the operator strictly worse than
leaving it out. `japanese AND india` returned nothing while the documents said
"Indian", and the fix was to treat the operator as structure rather than as a
declaration of precision.

Two edges worth knowing, because both are the retry rule rather than bugs:

* The retry fires only when the result is **empty**. `india OR aerial` where
  `aerial` matches keeps that exact result, and the near-miss term contributes
  nothing.
* On the retry inside a boolean the edit distance is 1 for every term, because it is
  applied per FIELD and cannot vary by token length. A plain bag of words allows 1
  edit for words up to six characters and 2 beyond.

Fuzzy is for spelling you are unsure of, not for widening a search that returned
too little — it returns more, ranked worse.

## Explore a corpus you know nothing about

```
GET /search?q=*&facets=true&limit=0
```

```json
{ "facets": { "modality": { "audio": 812, "document": 4103 },
              "kind": { "transcript": 9044, "doc_chunk": 12888 } } }
```

Counts first, then drill in with `modality` / `kind`. This is the right opener
when you have just been given access and do not know what is there.

## Page through results

```
GET /search?q=invoice&limit=50&offset=50
```

`limit` caps at 100. `offset` caps at 10,000 — past that, narrow with a filter or
switch to `sort=recent` and page by time. Deep offsets get slower; a time window
does not.

## Walk the whole catalogue

Search finds things; this enumerates them.

```
GET /assets?limit=100&modality=video
```

```json
{ "assets": [ … ],
  "next": { "after_uploaded_at": 1738368000, "after_asset_id": "9f2c…" } }
```

Pass the fields of `next` straight back to continue, and stop when it is `null`:

```
GET /assets?limit=100&modality=video&after_uploaded_at=1738368000&after_asset_id=9f2c…
```

A cursor costs the same on the thousandth page as on the first, which an
`offset` does not. Filter by `modality` and by `status` (`indexed` is
searchable; `pending` is still processing; `review` is held for content review;
`rejected` was refused). Not billed.

There is no public asset catalogue — without a tenant key this returns nothing.
Public *passages* are searchable; enumerating another workspace's files is not
something this API offers.

## Read a whole asset without downloading it

```
GET /asset/9f2c…
```

Returns the asset's metadata and **every** passage, with timecodes for audio and
video and page numbers for documents. This is the cheap way to read a document
end to end — no download, no bytes billed.

## Retry without paying twice

```
GET /search?q=…&request_id=550e8400-e29b-41d4-a716-446655440000
```

or, equivalently, as a header — which wins if you send both:

```
x-request-id: 550e8400-e29b-41d4-a716-446655440000
```

On MCP it is the `request_id` argument. Reuse a `request_id` **only** when
retrying the same search after a failure. The
retry is deduplicated and counted once. Reusing it for a genuinely different
query will silently under-count and give you the wrong answer about your own
usage; generating a fresh one per distinct search is correct.

## What you will not get back

- **Other tenants' private content.** A key sees its own workspace plus anything
  public. No parameter widens that.
- **Moderated content.** Refused content is served to nobody, and restricted
  content only to the workspace that uploaded it.
- **Stale results.** Visibility and moderation are re-checked against the
  authoritative catalog on every call, so a result can disappear between two
  identical searches if something changed in between. `total_relation` becomes
  `approximate` when that happens. This is deliberate: the search index is a
  projection that lags, and the catalog is the truth.

## Errors worth handling

| Status | Meaning |
|---|---|
| `401` | missing or unknown key |
| `402` | out of credit, or over your plan's search allowance; the body names what was needed |
| `429` | over your key's per-minute rate; back off and retry |
| `503` | search is saturated; retry with backoff |
| `410` | that download link was already used |
| `413` | the asset is larger than the download limit |

## Over MCP

The same operations are exposed as MCP tools — `search`, `get_asset`,
`list_assets`, `request_download` — with identical parameters. Fetch
`/mcp/tools.json` for the machine-readable schemas, or point an MCP client at
`https://api.ipto.ai/mcp`:

```json
{ "mcpServers": { "ipto": {
    "url": "https://api.ipto.ai/mcp",
    "headers": { "x-api-key": "ipto_…" } } } }
```

`Authorization: Bearer ipto_…` works in place of `x-api-key`, if your client
prefers it.

**Transport.** Streamable HTTP. Protocol versions `2024-11-05`, `2025-03-26`,
`2025-06-18` and `2026-07-28` are supported and negotiated — offer yours in
`initialize` and the server replies with the one it chose. The endpoint is
stateless: it issues no session you have to keep alive, and any replica can serve
any request.

**Payment errors do not break the transport.** Running out of credit comes back
as an ordinary tool result with `isError`, naming `required_credits` and
`available_credits`, so the session stays usable and you can top up and retry.
Only authentication failures are HTTP statuses, because a client with no usable
key has no session to put an error into.

Every recipe above works unchanged as a tool call; `GET /search?q=x&mode=hybrid`
becomes `search` with `{"query": "x", "mode": "hybrid"}`. The parameters are
identical on both transports — a test fails the build if they ever diverge — with
one spelling difference: the query is `q` over HTTP and `query` over MCP, each
following its own convention.

The one thing MCP cannot do is hand you bytes. `request_download` mints the URL;
fetching it is an ordinary HTTP GET, because JSON-RPC is a poor way to move a
two-gigabyte video.
