API
API reference
The machine-readable contract
Everything on this page is rendered from openapi.yaml — the same file, served as it is written. Point a generator at it rather than transcribing this page.
Streaming
Send "stream": true and you get text/event-stream — chat.completion.chunk frames and a final data: [DONE], the shape an OpenAI client already handles.
The last chunk carries the untold block, because the privacy mode delivered, the receipt and the salts do not exist until the answer is finished. Two differences worth knowing before you rely on them: there is no usage frame, since the upstream does not always report one and we will not invent the number; and the default stays non-streaming, which is the stronger guarantee — a buffered answer can refuse before showing you anything, and a stream cannot.
What comes back that OpenAI does not send
"untold": {
"privacy_mode": … // What this answer was ACTUALLY delivered under, not what was requested.
"privacy_label": … // The same fact in the words shown on the answer itself.
"content_policy": … // Uncensored is what a paid plan buys. Privacy is not.
"receipt_digest": … // sha256 of the canonicalised receipt body — what the signature is over.
"receipt": … // Signed, and verifiable offline without us.
"commitment_salts": … // Returned to YOU and never stored by us. These are the inputs that let you open `responseCommit` from the answer and `promptCommit` from your own `messages` — which is how a receipt is checked against reality rather than merely against itself: `sha256(salt + ":" + JCS(content))`, where the response content is `{role:"assistant", content}` and the prompt content is the `messages` array exactly as you sent it. The answer's commitment opens on every request that returns a receipt. The prompt commitment covers the conversation exactly as you sent it, on every surface and whether or not the answer streamed, and nothing Untold adds is inside it: Untold's own instructions, your saved memory and the documents you attached are outside it, pictures you attached are inside it, so you can open it with your own messages. A run of several steps commits every step to that same conversation, and a receipt without the field promptScope set to sent was issued before this rule and may not open from your messages (issue #11).
"enclave_receipt_id": … // optional — present only when the gateway issued an ACI receipt.
"resolved_via_trait": … // optional — present only when a trait name resolved the model.
"why": … // optional — present only when the answer was delivered anonymized or private.
}The salts are how a receipt commits to your prompt without containing it. They are sent once, to you, and we keep no copy — so we cannot open the commitment either.
End-to-end encryption
The strongest mode, and available to you on the same terms as the web app — your key, your device. Fetch the enclave's attested public key, encrypt the message content to it, and post the ciphertext:
GET /api/e2ee/key?model=untold-uncensored # -> algo, public_key, upstream_model, measurement
POST /api/e2ee/relay # the same body, content fields encrypted
headers: X-E2EE-Version: 2 · X-Client-Pub-Key · X-Model-Pub-Key
X-E2EE-Nonce (32 random bytes, hex) · X-E2EE-Timestamp (unix seconds)Encrypt under upstream_model, not the id you asked for. The associated data binds the request's model string byte-exactly, and we forward the upstream name — that is why the key endpoint tells you what it is rather than leaving you to guess and fail at the last step.
We cannot finish the proof for you, and that is the point. The enclave's receipt commits to the DECRYPTED request, which we never hold. We verify every other half — the Intel chain, the pinned measurement, the workload identity, the receipt's binding to that quote, that the upstream model was itself attested, and its commitment to this exact response — then hand you the receipt. Hash your own request body and compare it to the request.received event to complete it.
On this path we cannot moderate what you send and do not store the exchange. That is not a policy we are choosing; it is a consequence of not being able to read it.
Refusals are the product working
503 attestation_unavailable— no enclave could be verified right now.503 unverified_enclave— an answer came back without provable provenance.409 shown_mode_stale— you sentshown_modeand we would have delivered something weaker.403 not_entitled— that model needs a paid plan.429— rate limited.
A refusal never carries an answer. If you get content, it was proved.
Other endpoints
GET /v1/models— the catalogue with each model's mode (28 models, 6 sealable).GET /v1/models/traits— stable names that outlive a model.GET /api/limits— what we do not promise, computed from the running system.GET /api/metrics— the numbers that would catch us lying.
This is what you get back
A real receipt, from a sealed answer this system produced on . The signature is EIP-191 over RFC 8785 canonical JSON, so ordinary Ethereum tooling reads it and no call back to us is needed.
{
"body": {
"v": 1,
"issuedAt": "2026-08-26T08:38:43Z",
"model": "untold-uncensored",
"mode": "tee",
"promptCommit": "7c8f92a4df1df84a7de62ecc29bad7fbafff651f49e55334747ff7b3806022c3",
"responseCommit": "9b6709e2d77f23a5317fbdefe25939b137e6b7140fd4a0517b9b9262134608e4",
"enclave": {
"providerId": "phala-aci-v1",
"measurement": "bd369a8c2f9edb2b52dad48ac8e0b32dde5f1337c423a506b48d07403a7d8033",
"verifiedBy": "intel-rooted-v1"
}
},
"digest": "01429c567285cd096ad51512e8e0b1c661ca5ba0736b0b13ac657cd12e36091e",
"signature": "0x19f5aff315109319f55339fd9b75f10ba854ca516e29a935ae5f618b9137fb2e7fd11cb144cc1ab119ad591a45c8d79f3ac0fa6f5446b5ce810a1da7b1fb74611c",
"signer": "0x95A66186d7D2b5856491370e5F5182D1F10DA085"
}Or check a different one — yours, or one you were sent — at /verify.
No network call was made to check it.
Every field here is a commitment or a measurement. The prompt and the answer are not in it — only hashes of them, which is why this one can sit on a public page. Opening a commitment needs the salts, and those go to the person who asked, not into this file. The address that signed it is published at /limits, and checking against it is what separates “internally consistent” from “actually from Untold”.
Verifying a receipt
Our signing address is 0xA1641C53Be06C7d3370403e7daC01660A209F8D1, also at /api/limits as seal_signer. Fetch it from there rather than from a receipt: a signature checked against an address that arrived in the same file shows only that whoever wrote the file also signed it.
The verifier itself is not published yet — npx @askuntold/verify does not resolve and this repository is private. Until it is, the signature is EIP-191 over the sha256 digest of the canonicalized body — that is, compute digest = sha256(JCS(body)) and verify the EIP-191 signature over that 64-character hex string, not over the canonical bytes themselves. Ordinary Ethereum tooling checks it once you sign the right message.
Opening a commitment
A receipt carries a salted commitment to your prompt rather than a hash of it, so nobody holding a filed receipt can confirm a guess. The salts come back to you in untold.commitment_salts and are never stored, which is also why only you can do this:
promptCommit = sha256( salts.prompt + ":" + JCS(request.messages) )
responseCommit = sha256( salts.response + ":" + JCS(response.choices[0].message) )The answer's commitment opens on every request that returns a receipt. The prompt commitment covers the conversation exactly as you sent it, on every surface and whether or not the answer streamed, and nothing Untold adds is inside it: Untold's own instructions, your saved memory and the documents you attached are outside it, pictures you attached are inside it, so you can open it with your own messages. A run of several steps commits every step to that same conversation, and a receipt without the field promptScope set to sent was issued before this rule and may not open from your messages (issue #11). The verifier (untold-verify answer.json --request request.json, the same opening code that runs at /verify; the npm package is unpublished) opens both for you. On a receipt whose body carries promptScope: "sent" a prompt that does not open has one meaning — the request is not the one that was sent; on an older receipt without it, the verifier prints both readings, because such a receipt cannot say which applies.
On a run of several calls (a v2 receipt), commitment_salts holds one pair per call, and every call's promptCommit opens with its own pair against the same request.messages.
On the agent (the app's /agent page), the request carries a prompt rather than messages, so the array committed over is rebuilt from your body by one rule: your history as you sent it (none if absent), then one user turn whose content is your prompt as a string — or, when you sent images, a text part followed by one image part per image, in the order you sent them. Nothing is trimmed or dropped, and memory and attached documents are outside it. untold-verify --request accepts the agent body you sent and applies the same rule.
request.messages = [ ...history, { role: "user", content: images.length
? [{ type: "text", text: prompt }, ...images.map(url => ({ type: "image_url", image_url: { url } }))]
: prompt } ]JCS is RFC 8785 canonical JSON, the same canonicalisation the signature uses, and the separator is a literal colon. Recompute either value with your saved salt and the exact JSON you sent; if it matches the receipt, you have proved what you asked without revealing it to anyone.