> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pool.day/llms.txt
> Use this file to discover all available pages before exploring further.

# Service API

> Every call PoolKit makes to Pool. You never call them yourself; this page is for security reviews.

All calls go to `https://actions.pool.day` over TLS with the app key as a bearer token. None carries an image, a file name or a photo identifier. By default PoolKit makes only the first one.

| Call | When | What it carries |
| - | - | - |
| `POST /sdk/v1/installs` | When the client opens | A random install ID and your bundle ID |
| `POST /sdk/v1/models` | Before the engine downloads its models | A random install ID and your bundle ID |
| `POST /sdk/v1/extract` | Cloud assist, naming things | Text read from a capture |
| `POST /sdk/v1/answer` | Cloud assist, answering a question | The question, and things and short excerpts PoolKit already found |
| `POST /sdk/v1/profile` | Cloud assist, filling a profile | Your schema, and things and short excerpts PoolKit already found |
| `POST /sdk/v1/classify` | Cloud assist, judging captures against a rule | Your rule, and text, labels and thing names from the captures PoolKit is unsure about |
| `POST /sdk/v1/attest/challenge` | [App Attest](https://docs.pool.day/app-attest.md), before the first cloud assist call | A random install ID and your bundle ID |
| `POST /sdk/v1/attest` | App Attest, once per key | Apple's attestation of a key made on the device |
| `POST /sdk/v1/runs` | After an indexing run, only with `sendsRunReports` | Timings and counts of the run: see [Run reports](https://docs.pool.day/keys.md#run-reports) |

## Register an install

```http theme={null}
POST /sdk/v1/installs
Authorization: Bearer pk_live_…

{ "installId": "0c1d2dbc-…", "bundleId": "com.example.app", "platform": "ios", "sdkVersion": "0.1.0" }
```

```json theme={null}
{
  "status": "active",
  "plan": "build",
  "cloudAssist": { "enabled": true },
  "attestation": { "mode": "off" },
  "key": { "expiresAt": null }
}
```

`key.expiresAt` is set while a rotated key is in its 30-day grace.

When cloud assist is off for the install, `cloudAssist` says why. `{ "enabled": false, "reason": "disabled" }` means the app turned it off in the dashboard. `{ "enabled": false, "reason": "quota_exceeded", "retryAfter": 86400 }` means the month's installs or cloud assist units are spent; the SDK registers again after `retryAfter` seconds and picks up a raised allowance or a new month. Everything on the device keeps working either way.

Sent when the client opens. The install ID is random, made by the SDK, stored in your app's container, and reset when the app is reinstalled. It is how connected users are counted: once per install per calendar month.

## List the engine's models

```http theme={null}
POST /sdk/v1/models
Authorization: Bearer pk_live_…

{ "installId": "0c1d2dbc-…", "bundleId": "com.example.app" }
```

```json theme={null}
{
  "assets": [
    { "assetId": "siglip2-vision", "version": 3, "totalByteCount": 96468992, "files": [
      { "relativePath": "vision.mlmodelc/weights/weight.bin", "url": "https://…", "sha256": "9f2c…", "byteCount": 96201728 }
    ] }
  ]
}
```

PoolKit downloads each file once, checks it against the SHA-256 given here before loading it, and keeps it in your app's container. The models run on the device. Nothing about a photo is part of this call.

## Cloud assist: name things

```http theme={null}
POST /sdk/v1/extract
Authorization: Bearer pk_live_…

{
  "installId": "0c1d2dbc-…",
  "bundleId": "com.example.app",
  "items": [
    { "ref": "a1", "source": "screenshots", "text": "Nike Air Max 90\n€149.99", "labels": ["shoe"] }
  ]
}
```

```json theme={null}
{
  "results": [
    { "ref": "a1", "things": [
      { "type": "product", "label": "Nike Air Max 90", "attributes": { "price": "€149.99" }, "confidence": 0.9 }
    ] }
  ]
}
```

## Cloud assist: answer a question

```http theme={null}
POST /sdk/v1/answer
Authorization: Bearer pk_live_…

{
  "installId": "0c1d2dbc-…",
  "bundleId": "com.example.app",
  "question": "Which sneakers did I save?",
  "context": [
    { "ref": "t1", "kind": "thing", "type": "product", "label": "Nike Air Max 90",
      "attributes": { "price": "€149.99" }, "count": 3, "firstSeen": "2026-03-02", "lastSeen": "2026-10-20" },
    { "ref": "c1", "kind": "capture", "text": "Nike Air Max 90 €149.99 Add to bag" }
  ]
}
```

```json theme={null}
{ "answer": { "text": "You saved the Nike Air Max 90 three times since March.", "citations": ["t1"], "declined": false } }
```

* The context is what PoolKit found on the device under the grant: up to 40 things and excerpts of up to 400 characters. A `ref` is a random reference that means nothing outside the request.
* Every citation is a `ref` from the request. Pool drops any other, so an answer cannot point at something the device did not send.
* `declined` is true, with no text, when answering would mean inferring something sensitive or identifying a person.
* Pool does not store or log the question, the context or the answer.
* A question is 1 to 1,000 characters.

With `Accept: text/event-stream`, the same call answers as the model writes, for `askUpdates`. A refusal before the first word is the usual JSON error. Otherwise the body is Server-Sent Events:

```text theme={null}
event: delta
data: {"text":"You saved the Nike "}

event: delta
data: {"text":"Air Max 90 three times since March."}

event: answer
data: {"answer":{"text":"You saved the Nike Air Max 90 three times since March.","citations":["t1"],"declined":false}}
```

* `delta` events carry the next words. The final `answer` is checked like the JSON answer and can be `declined` after deltas, so drop the text you showed.
* When the model fails partway, the last event is `error` with `{"error":{"code","message"},"retryAfter":30}` and the units are given back.
* The 10 units are charged once the `answer` event is sent. If the app closes the stream before the first `delta`, they are given back; once words have arrived, closing it keeps the charge.

## Cloud assist: fill a profile

```http theme={null}
POST /sdk/v1/profile
Authorization: Bearer pk_live_…

{
  "installId": "0c1d2dbc-…",
  "bundleId": "com.example.app",
  "schema": {
    "type": "object",
    "properties": {
      "brands": { "type": "array", "items": { "type": "string" }, "description": "Brands in saved products" },
      "shoeSize": { "type": "number" }
    }
  },
  "context": [ { "ref": "t1", "kind": "thing", "type": "product", "label": "Nike Air Max 90", "count": 3 } ]
}
```

```json theme={null}
{
  "profile": {
    "value": { "brands": ["Nike"], "shoeSize": null },
    "evidence": { "/brands": ["t1"] },
    "refused": { "/shoeSize": "no_evidence" }
  }
}
```

* Up to 80 context entries. The schema is a small part of JSON Schema: objects, arrays, strings, numbers, integers, booleans and enums, up to 5 levels, 100 fields and 32 KB. A schema outside that gets a 400 with code `invalid_schema` and a sentence that says why.
* A field is filled only when its value fits the schema and rests on at least one `ref` from the request. Every other field is `null` and listed in `refused` with `no_evidence`, `sensitive` or `invalid`.
* Fields about health, religion, sexuality, ethnicity, politics, children, finances or credentials are never filled, whatever the evidence.
* Pool does not store or log the schema, the context or the profile.

## Cloud assist: judge captures against a rule

```http theme={null}
POST /sdk/v1/classify
Authorization: Bearer pk_live_…

{
  "installId": "0c1d2dbc-…",
  "bundleId": "com.example.app",
  "rule": "sneakers I might buy",
  "items": [
    { "ref": "c1", "source": "screenshots", "text": "Nike Air Max 90\n€149.99", "labels": ["shoe"], "things": ["Nike Air Max 90"] },
    { "ref": "c2", "source": "photos", "labels": ["sneaker", "footwear"] }
  ]
}
```

```json theme={null}
{
  "results": [
    { "ref": "c1", "belongs": 0.93, "incidental": 0.02, "insufficient": 0.05, "member": true }
  ],
  "retryAfterSeconds": 5
}
```

* `rule` is up to 500 characters of plain words. `items` holds up to 24 captures; each needs text, a label or a thing name.
* `belongs` is the probability that the capture is what the rule describes. `incidental` is the probability that it is system or app chrome instead of something the person saved. `insufficient` is the probability that the text is too thin to judge. `member` is true when `belongs` is at least 0.7 and `incidental` is under 0.45.
* A capture missing from `results` was not reached this time. `retryAfterSeconds` is present only then and says when to send it again.
* Four captures count as one unit, rounded up per request. Captures that were not reached are not charged.
* A rule about health, religion, sexuality, ethnicity, politics, children, finances or credentials is refused with 400 and the code `rule_not_allowed`, before anything is read.
* Pool does not store or log the rule or the text. It logs the three numbers per `ref`, which mean nothing outside the request.

## App Attest

Used only when [App Attest](https://docs.pool.day/app-attest.md) is on for your app, as the install response's `attestation.mode` says.

```http theme={null}
POST /sdk/v1/attest/challenge
Authorization: Bearer pk_live_…

{ "installId": "0c1d2dbc-…", "bundleId": "com.example.app" }
```

```json theme={null}
{ "challenge": "q9X3…", "expiresInSeconds": 300 }
```

```http theme={null}
POST /sdk/v1/attest
Authorization: Bearer pk_live_…

{ "installId": "0c1d2dbc-…", "bundleId": "com.example.app", "keyId": "mF2a…", "attestation": "o2NmbXRv…", "challenge": "q9X3…" }
```

```json theme={null}
{ "status": "attested" }
```

* A challenge works once, for the install it was made for, for five minutes. Pool keeps only its hash.
* The attestation is checked against Apple's App Attestation root, the challenge, the key ID and `<team ID>.<bundle ID>`. A live key accepts only production keys. Anything else is a 400 with code `attestation_invalid`.
* Then every cloud assist request carries `X-Pool-Attest-Key: <key ID>` and `X-Pool-Assertion: <base64 assertion>`, signed over SHA-256 of the exact body bytes. Each assertion's counter is accepted once.
* Under Enforce, a request without a valid assertion gets 401 with `attestation_required`, or `attestation_key_unknown` when the key is not on file. PoolKit attests again once and retries.

## Run reports

```http theme={null}
POST /sdk/v1/runs
Authorization: Bearer pk_live_…

{
  "installId": "0c1d2dbc-…",
  "bundleId": "com.example.app",
  "report": {
    "runId": "4f1c9a2e-…", "kind": "first", "outcome": "completed",
    "startedAt": "2026-11-12T10:00:00Z", "durationMs": 92000,
    "counts": { "total": 1200, "indexed": 1150, "skipped": 40, "failed": 10 },
    "timings": { "readingLibrary": 2000, "indexing": 88000 },
    "peakMemoryMB": 180, "sdkVersion": "0.2.0", "osVersion": "26.1", "deviceModel": "iPhone17,1"
  }
}
```

Answers 202 `{ "status": "recorded" }`. Any field beyond these is a 400. Kept 90 days.

## Status codes

| Status | Meaning |
| - | - |
| 200 | Success. |
| 400, 413 | The request body is malformed or too large. |
| 401 | The key is unknown or revoked, or App Attest is enforced and the request is not attested (`attestation_required`, `attestation_key_unknown`). |
| 403 | The key is not allowed for this bundle ID (`bundle_not_allowed`), the app is turned off (`app_disabled`) or waits for approval (`app_pending`). |
| 429 | Rate limit or allowance reached. `Retry-After` says when to come back. |
| 503 | Pool is briefly unavailable. `Retry-After` says when to come back. |

Error bodies look like `{ "error": { "code": "quota_exceeded", "message": "…" } }` and never repeat what was sent.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.