This is the full developer documentation for Prismlet API
# Prismlet API
> Send a text to a prism and get its answers back in the same response.
A prism is a set of typed questions you build in the Prismlet app. Send it a text and the answers come back in the same response, one value per column of the prism's Lens. Every answered call is saved as a result that you can read again by its `id`, and retry if it failed.
## One call
This asks the `support-routing` prism which team should handle a ticket, whether it is urgent and how the customer sounds.
* curl
```sh
curl https://api.prismlet.com/v1/prisms/support-routing/results \
-H "Authorization: Bearer $PRISMLET_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"ref": "TCK-8812",
"fields": {
"subject": "Charged twice this month",
"text": "Hi, I was billed twice for September. Please refund the second charge before Friday."
}
}'
```
* JavaScript
first-call.mjs
```js
const response = await fetch('https://api.prismlet.com/v1/prisms/support-routing/results', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PRISMLET_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
ref: 'TCK-8812',
fields: {
subject: 'Charged twice this month',
text: 'Hi, I was billed twice for September. Please refund the second charge before Friday.',
},
}),
signal: AbortSignal.timeout(45_000),
});
const result = await response.json();
console.log(response.status, result);
```
* Python
first\_call.py
```python
import os
import requests
response = requests.post(
"https://api.prismlet.com/v1/prisms/support-routing/results",
headers={"Authorization": f"Bearer {os.environ['PRISMLET_API_KEY']}"},
json={
"ref": "TCK-8812",
"fields": {
"subject": "Charged twice this month",
"text": "Hi, I was billed twice for September. Please refund the second charge before Friday.",
},
},
timeout=45,
)
result = response.json()
print(response.status_code, result)
```
200 OK
```json
{
"id": "0192f5c1-7c1a-7b3e-9f55-3c1d2e4a5b6c",
"ref": "TCK-8812",
"prism": "support-routing",
"version": 3,
"status": "ok",
"error": null,
"took_ms": 640,
"created_at": "2026-09-23T09:14:02.187Z",
"answers": {
"team": "billing",
"team_probability": 0.91,
"urgent": "yes",
"tone": "annoyed",
"tone_average": 2.05
}
}
```
`team` is an option key. `urgent` is `yes` because the Lens turns the probability of yes into a label. `tone_average` places the tone between Calm (1) and Angry (3). The call waits for the answers, so there is nothing to poll.
This response uses the custom Lens from the [quickstart](/quickstart/#make-a-prism-and-try-it). With the automatic Lens, `answers` holds `team`, `urgent` and `tone`.
## Base URL
The base URL is `https://api.prismlet.com/v1`. That address is not live yet. Paths in these docs start with `/v1`, except in the [API reference](/api/), which writes them relative to the base URL.
The API has three endpoints:
| Method | Path | What it does |
| ------ | ------------------------------- | ----------------------------- |
| `POST` | `/v1/prisms/{prism}/results` | Answer one text with a prism. |
| `GET` | `/v1/results/{result_id}` | Read a saved result. |
| `POST` | `/v1/results/{result_id}/retry` | Retry a failed result. |
## Where to go next
* [Quickstart](/quickstart/) takes you from an empty account to this call in five minutes.
* [Prisms, fields and questions](/concepts/prisms/) and [the Lens](/concepts/lens/) explain what the answers mean.
* [Errors and retries](/guides/errors-and-retries/) shows how to make a call safe to repeat.
* The [API reference](/api/) lists every parameter, schema and status. Download the OpenAPI 3.1 document at [`/openapi.json`](/openapi.json) to generate a client or import it into an HTTP tool.
* AI assistants can read [`/llms.txt`](/llms.txt), or [`/llms-full.txt`](/llms-full.txt) for every guide in one file.
# Quickstart
> Make a prism, create an API key and answer your first text.
You'll build a prism that routes support tickets, then send it a ticket from code. You need a Prismlet account and one of curl, Node 22 or later, or Python with `requests` installed.
Prismlet accounts are invite-only for now. Ask your Prismlet contact for an invite.
## Make a prism and try it
1. In the app, open **Prisms**, choose **Blank prism**, name it Support routing and choose **Create**. That opens a draft. The prism is saved the first time you choose **Create prism** or import. Its slug becomes `support-routing`, and that is the name you put in the URL. Renaming the prism later does not change the slug.
2. On the **Questions** tab, choose **Import questions**, paste this JSON and choose **Import and save**. Import replaces the fields, context and questions and saves the prism.
```json
{
"fields": [
{ "key": "subject", "label": "Subject" },
{ "key": "text", "label": "Text" }
],
"context": "We sell scheduling software to clinics.",
"questions": [
{
"key": "team",
"type": "choice",
"text": "Which team should handle it?",
"instructions": "Decide which team should handle the latest customer message. Use the subject only as supporting context.",
"options": [
{
"key": "billing",
"label": "Billing",
"description": "Invoices, payments, refunds, plan changes."
},
{ "key": "technical", "label": "Technical support" }
]
},
{
"key": "urgent",
"type": "yes_no",
"text": "Does the customer explicitly describe immediate time pressure?"
},
{
"key": "tone",
"type": "scale",
"text": "How does the customer sound?",
"options": [
{ "key": "calm", "label": "Calm" },
{ "key": "annoyed", "label": "Annoyed" },
{ "key": "angry", "label": "Angry" }
]
}
]
}
```
The prism reads two fields, `subject` and `text`, and asks three questions: a Choice, a Yes / No and a Scale. [Prisms, fields and questions](/concepts/prisms/) explains each part.
3. Use **Try** in the app to test a prism before you write code. Paste a real ticket into the fields, add a reference if you have one to match against your own records, and run it. Every try is saved as a result, so you can come back to one that looked wrong.
4. Optional: on the **Lens** tab, add a column for the probability of `team` and one for the average of `tone`. The app names them `team_probability` and `tone_average` and adds them at the end, so move each up under its question's column to match the examples here. On `urgent`, tick **Unsure in between**, then set **No below** to 40% and **Yes from** to 60%. Choose **Save Lens**. [The Lens](/concepts/lens/) covers every output.
## Create an API key
Open **API keys** and create a key. Give it a label that names the system that will use it. The app shows the full key once, so copy it now. If you lose it, create another and revoke the old one.
Put the key in an environment variable in the shell you'll run the examples from:
```sh
export PRISMLET_API_KEY="pl_..."
```
A key works for every prism in your account. Keep it on your server. [Authentication](/guides/authentication/) explains why browser calls fail.
## Send your first text
* curl
```sh
curl https://api.prismlet.com/v1/prisms/support-routing/results \
-H "Authorization: Bearer $PRISMLET_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"ref": "TCK-8812",
"fields": {
"subject": "Charged twice this month",
"text": "Hi, I was billed twice for September. Please refund the second charge before Friday."
}
}'
```
* JavaScript
first-call.mjs
```js
const response = await fetch('https://api.prismlet.com/v1/prisms/support-routing/results', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PRISMLET_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
ref: 'TCK-8812',
fields: {
subject: 'Charged twice this month',
text: 'Hi, I was billed twice for September. Please refund the second charge before Friday.',
},
}),
signal: AbortSignal.timeout(45_000),
});
const result = await response.json();
console.log(response.status, result);
```
* Python
first\_call.py
```python
import os
import requests
response = requests.post(
"https://api.prismlet.com/v1/prisms/support-routing/results",
headers={"Authorization": f"Bearer {os.environ['PRISMLET_API_KEY']}"},
json={
"ref": "TCK-8812",
"fields": {
"subject": "Charged twice this month",
"text": "Hi, I was billed twice for September. Please refund the second charge before Friday.",
},
},
timeout=45,
)
result = response.json()
print(response.status_code, result)
```
The prism's **Integrate** tab shows the curl version with your prism's slug and field keys filled in, and the response its Lens gives.
## Read the answer
A `200` means the text was answered and saved:
200 OK
```json
{
"id": "0192f5c1-7c1a-7b3e-9f55-3c1d2e4a5b6c",
"ref": "TCK-8812",
"prism": "support-routing",
"version": 3,
"status": "ok",
"error": null,
"took_ms": 640,
"created_at": "2026-09-23T09:14:02.187Z",
"answers": {
"team": "billing",
"team_probability": 0.91,
"urgent": "yes",
"tone": "annoyed",
"tone_average": 2.05
}
}
```
This response uses the Lens from step 4. With the automatic Lens, `answers` holds `team`, `urgent` and `tone`.
| Key | What it holds |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | The result id. Read the result again with `GET /v1/results/{id}`. |
| `ref` | The reference (`ref`) you sent, or `null`. |
| `prism` | The prism's slug. |
| `version` | The prism version that answered. Saving the prism creates version 1 and each save adds one, so after the import yours is 1. The example shows 3. |
| `status` | `ok`, or `failed` when the model gave no usable answer. |
| `error` | Why the result failed, or `null`. |
| `took_ms` | How long the model took, in milliseconds. |
| `created_at` | When the result was saved, ISO 8601 in UTC. |
| `answers` | One entry per Lens column, in Lens order. `null` when the result failed. |
Answers use keys, not labels: `billing` rather than Billing, `yes` rather than Yes. Your code can compare them without worrying that someone fixed a typo in a label. [Formats](/guides/formats/#keys-and-labels) shows where labels appear instead.
A call can also come back as `502` with `status: "failed"`. The failed result is saved and has the same shape, with `answers: null` and an `error`. [Errors and retries](/guides/errors-and-retries/) shows how to handle it.
## Next
* [Sending a text](/guides/sending-a-text/) covers `ref`, pinning a version and the size limits.
* [Formats](/guides/formats/) shows every probability with `format=raw`, and the result as a CSV row.
* [Errors and retries](/guides/errors-and-retries/) makes a call safe to repeat with `idempotency_key`.
* [Answering many texts](/guides/many-texts/) works through a whole list, a few calls at a time.
# Prisms, fields and questions
> What a prism holds, the three question types, and how versions work.
A prism is a named set of fields, optional context and questions. You build and test it in the Prismlet app, and the API runs it on one text per call. Saving a prism creates its next version.
## Naming a prism in a URL
The `{prism}` in `/v1/prisms/{prism}/results` is the prism's slug or its id.
* The slug is set from the name when the prism is created, such as `support-routing` for Support routing. Renaming the prism does not change it, so your URLs keep working.
* The id is a UUID. A value shaped like a UUID is always read as an id, which is why no slug can look like one.
* An archived prism answers `404` for new calls and retries. Its results stay readable.
## Fields
A field is a named piece of text you send with each call. It has a key, such as `subject`, and a label, such as Subject, which the app shows.
* A prism has one to eight fields.
* A key is snake\_case: a lowercase letter, then up to 63 lowercase letters, digits or underscores.
* `context` is reserved, and so are the row names `result_id`, `ref`, `version`, `status`, `error` and `created_at`.
* Once the prism is saved, a field's key is fixed, because your code sends it. To send a different key, add a new field and remove the old one.
Every call sends exactly the field keys of the prism version it runs against. A missing key and an extra key are both a `400`. [Sending a text](/guides/sending-a-text/) has the details.
## Context
Context is optional background that every question sees, such as "We sell scheduling software to clinics." It is part of the prism, holds up to 8,000 characters, and counts toward the size of every call. A call can add background of its own with [`context`](/guides/sending-a-text/#context-for-one-call-context), which the model reads after the prism's.
## Questions
A question is one judgment about the fields. It has a key, a type, the question `text` the app shows, and optional `instructions`. The model reads `text` first, then `instructions` when present, so instructions add detail to the question and need not repeat it. A prism has one to 100 questions, and question keys share one namespace with field keys. Once saved, a question keeps its key and its type, so your code always reads the same shape of answer under a key. For another key or type, add a new question. Saving a version that gives a question key from any earlier version a different type is refused with a `400`.
There are three types.
| Type | In the app | Asks for | Options | Stored answer |
| -------- | ---------- | ------------------------------ | --------------------- | ------------------------------------------------- |
| `choice` | Choice | One option from a list | 2 to 253 | `value`, `probabilities` per option, `confidence` |
| `yes_no` | Yes / No | The probability that it is yes | None | `probability` of yes, from 0 to 1 |
| `scale` | Scale | One level of an ordered list | 2 to 10, lowest first | `level`, `probabilities` per level |
Options and levels have a key, a label and an optional description. The description is what the model reads to tell options apart, so write one whenever two options could be confused. The app derives an option key from its label. Once the option is saved, the key stays when you edit the label.
### Choice
A Choice answers with one option key. Its stored answer has a probability for every option and the model's `confidence` in the choice.
Two more outcomes are always possible, and every Choice has them without you adding them:
| Key | Label | Means |
| -------------------------- | ---------------------- | --------------------------------------- |
| `other` | Other | None of the options fit the text. |
| `insufficient_information` | Not enough information | The text does not say enough to choose. |
You can't use these keys for your own options. Handle both in your code. A router that only knows `billing` and `technical` needs a path for a ticket that is neither.
A Choice `value` is `null` when the model named an option the prism does not have. The app shows it as "No valid answer". It is not an error, and the result's `status` is still `ok`.
### Yes / No
A Yes / No answer is only a probability. It has no stored label. The Lens turns the probability into `yes`, `no` or `unsure` with cutoffs you choose, and you can change them later without asking the model again. See [Yes / No cutoffs](/concepts/lens/#yes--no-cutoffs).
### Scale
A Scale has ordered levels, written lowest first, such as Calm, Annoyed, Angry. Its stored answer is the most probable `level` and a probability for every level. On a tie the lower level wins. The Lens can also return the level's position or the expected level number.
## Versions
Saving a prism creates the next version, numbered from 1. A saved version never changes.
* Every save creates a new version, whether it changed the fields, the context, the questions, their options, the name or nothing at all.
* Editing the Lens is not a version.
* Calls use the latest version unless the body names another with `version`.
* Every result records the version that answered it.
Pin `version` when your code depends on a version's field keys, for example while someone adds a field in the app that your code does not send yet. An unknown version answers `404` with `Prism version not found`.
# The Lens
> The ordered output columns of a prism, the outputs per question type, and Yes / No cutoffs.
The Lens is the ordered list of output columns of a prism. It decides what `answers` holds in the default `json` format, the answer columns of a CSV, TSV or JSON Lines row, and the Lens columns the app shows on a result page, on the Integrate tab and in downloads. A prism has one Lens, and you edit it on the prism's **Lens** tab.
## The automatic Lens
Until you save a custom one, a prism uses the automatic Lens, with one column per question named by the question key.
| Question type | Output | `support-routing` column |
| ------------- | ------- | ------------------------ |
| Choice | `top` | `team` |
| Yes / No | `label` | `urgent`, Yes from 0.5 |
| Scale | `level` | `tone` |
The automatic Lens follows the prism, so a question added in a new version adds its column. Saving a custom Lens freezes the columns, so a new question adds nothing until you add a column for it. **Reset to automatic** goes back.
## Outputs
Each column reads one question and returns one output. The values below come from this stored answer, which `format=raw` returns:
format=raw
```json
{
"id": "0192f5c1-7c1a-7b3e-9f55-3c1d2e4a5b6c",
"ref": "TCK-8812",
"prism": "support-routing",
"version": 3,
"status": "ok",
"error": null,
"took_ms": 640,
"created_at": "2026-09-23T09:14:02.187Z",
"answers": {
"team": {
"type": "choice",
"value": "billing",
"probabilities": {
"billing": 0.91,
"technical": 0.05,
"other": 0.03,
"insufficient_information": 0.01
},
"confidence": 0.88
},
"urgent": {
"type": "yes_no",
"probability": 0.83
},
"tone": {
"type": "scale",
"level": "annoyed",
"probabilities": {
"calm": 0.12,
"annoyed": 0.71,
"angry": 0.17
}
}
}
}
```
| Type | Output | Returns | `json` and `jsonl` |
| -------- | ------------- | -------------------------------------------- | ------------------------ |
| Choice | `top` | The chosen option | `"billing"` |
| Choice | `probability` | The chosen option's probability | `0.91` |
| Choice | `all` | Every option's probability | `{"billing": 0.91, ...}` |
| Yes / No | `label` | Yes, No or Unsure, from the column's cutoffs | `"yes"` |
| Yes / No | `probability` | The probability of yes | `0.83` |
| Scale | `level` | The most probable level | `"annoyed"` |
| Scale | `number` | That level's position, 1 for the lowest | `2` |
| Scale | `average` | The expected level number, to two decimals | `2.05` |
| Scale | `probability` | The most probable level's probability | `0.71` |
| Scale | `all` | Every level's probability | `{"calm": 0.12, ...}` |
CSV and TSV carry labels instead of keys, such as Billing, Yes and Annoyed, and an `all` cell reads `Billing 0.91; Technical support 0.05; Other 0.03; Not enough information 0.01`. [Formats](/guides/formats/#keys-and-labels) has the details. Probabilities are decimals from 0 to 1 in every format.
`average` is 1 × P(lowest) + 2 × P(next) and so on, so 0.12 × 1 + 0.71 × 2 + 0.17 × 3 = 2.05 here. It tells you where between the ends of the scale the answer sits, which `level` alone hides.
A Choice `top` whose stored `value` is `null` returns `null`, and so does its `probability`.
## Column names
A column's name is the key in `answers` and the header in a CSV row. The default name is the question key, with `_probability`, `_all`, `_number` or `_average` added for extra outputs, such as `team_probability`.
Names are snake\_case and unique. A column can't take a field key or one of the row names `result_id`, `ref`, `version`, `status`, `error` and `created_at`.
## Yes / No cutoffs
A `label` column turns the probability of yes into a word with two cutoffs:
* `no_below`: below it, the answer is No.
* `yes_from`: at it or above, the answer is Yes.
Between the two, the answer is Unsure. Without `yes_from`, Yes starts at `no_below` and there is no Unsure band. The automatic Lens uses `no_below` 0.5. `no_below` is strictly between 0 and 1. `yes_from` is at most 1 and above `no_below`, or above 0.5 when `no_below` is absent. The app's sliders set whole percents from 5% to 95%.
With `no_below` 0.4 and `yes_from` 0.6:
| Probability of yes | `json` | `csv` |
| ------------------ | ---------- | ------ |
| 0.35 | `"no"` | No |
| 0.4 | `"unsure"` | Unsure |
| 0.59 | `"unsure"` | Unsure |
| 0.6 | `"yes"` | Yes |
| 0.83 | `"yes"` | Yes |
An Unsure band is how you send the doubtful cases to a person instead of guessing.
## Lens edits relabel old results
Changing the Lens is not a new version and never asks the model again. Every read applies the current Lens to the stored answers, so a new cutoff relabels old results. If you raise `yes_from` to 0.9, the result above reads `"urgent": "unsure"` the next time you fetch it, from the API, the app or a download.
That is useful when you tune cutoffs against real results. It also means `answers` in `json` is not frozen. When a Lens edit must not change what your code reads, request `format=raw`, which returns the stored answers, and apply your own cutoffs. See [Formats](/guides/formats/#raw).
## When columns go away
* A result made by a version that lacks a column's question gets `null` for that column, or an empty cell.
* Saving a prism version that removes a question drops the custom Lens columns that read it, in the same save. The app names those columns and warns first.
* If a save drops every custom column, the prism falls back to the automatic Lens.
* Renaming or removing a column carries the same warning.
Code that reads `answers` should expect a column to be missing or `null` rather than fail on it.
## A custom Lens
The `support-routing` examples in these docs use this Lens, which the app stores as JSON:
```json
{
"columns": [
{ "name": "team", "question": "team", "output": "top" },
{ "name": "team_probability", "question": "team", "output": "probability" },
{ "name": "urgent", "question": "urgent", "output": "label", "no_below": 0.4, "yes_from": 0.6 },
{ "name": "tone", "question": "tone", "output": "level" },
{ "name": "tone_average", "question": "tone", "output": "average" }
]
}
```
# Results
> What a result stores, what OK and Failed mean, and how retries link results.
Prismlet sends each text to a language model, which answers the prism's questions. A result is one set of fields answered by one prism version. Every answered text becomes a result, whether it came from the API, from **Try** in the app or from a file run, and each one has a UUID `id`.
## What a result holds
| Stored | Returned by the API |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| The fields you sent | In CSV, TSV and JSON Lines rows, under the latest version's field columns. A field the latest version dropped is not returned. Not in `json` or `raw`. |
| Every probability | As `answers` with `format=raw`, and through the Lens with `format=json`. |
| The version that answered | As `version`. |
| Your reference (`ref`) | As `ref`, in every format. |
| `status` and `error` | In the envelope and in every row. |
| How long the model took | As `took_ms`. |
| When it was saved | As `created_at`, ISO 8601 in UTC with milliseconds, such as `2026-09-23T09:14:02.187Z`. |
Prismlet keeps the answers in full and applies the Lens each time you read a result. That is why [Lens edits relabel old results](/concepts/lens/#lens-edits-relabel-old-results).
## OK and Failed
A result is `ok` when every question came back with an answer. It is `failed` when the model gave no usable answer, because it timed out, was unreachable, returned an error or left a question unanswered.
A failed result is saved like any other. Its `answers` is `null` and its `error` says what went wrong:
502 Bad Gateway
```json
{
"id": "0192f5c3-2b4e-7d10-8a61-5e0f9c7d2a13",
"ref": "TCK-8812",
"prism": "support-routing",
"version": 3,
"status": "failed",
"error": "The model took too long to answer, so we stopped after 15 seconds.",
"took_ms": 15012,
"created_at": "2026-09-23T09:15:40.502Z",
"answers": null
}
```
`error` says in plain words why the result failed, such as `The model took too long to answer, so we stopped after 15 seconds.` Show or log it, but don't parse it, because the wording can change.
The call that made a failed result answers `502`. Reading it later with `GET /v1/results/{result_id}` answers `200`, because the read itself worked. Check `status` in the body.
## Retries
Retrying a failed result sends its stored fields to the model again, against the same prism version, and saves the answer as a new result with a new `id`. The old result keeps its `failed` status. The app links the two, but the API response carries no link, so keep the new `id` yourself.
[Errors and retries](/guides/errors-and-retries/#retry-a-failed-result) covers the rules and a repeat-safe way to call.
## Reference (`ref`)
`ref` is your own id for the text, such as a ticket number. You send it with the call, and it comes back in the envelope and in every row, so you can match a result to your record without storing our `id`. It does not have to be unique, and it is trimmed and at most 200 characters. The app's file runs use it for the row number when you don't map a column to it.
## Reading a result
`GET /v1/results/{result_id}` returns any result in your account, whichever key, person or file run made it. It never asks the model. Results of an archived prism stay readable.
```sh
curl https://api.prismlet.com/v1/results/0192f5c1-7c1a-7b3e-9f55-3c1d2e4a5b6c \
-H "Authorization: Bearer $PRISMLET_API_KEY"
```
It takes the same `format` as the other endpoints. Keep the `id` of every result you need to read again, because [no endpoint lists results](/reference/limits/#what-the-api-does-not-have).
# Authentication
> API keys, how to send them, revoking them, and why browser calls fail.
Every `/v1` call carries an account API key in the `Authorization` header:
```http
Authorization: Bearer pl_...
```
Write `Bearer` exactly like that, then one space, then the key. The word is case-sensitive.
## API keys
A key is `pl_` followed by 32 letters, digits, `-` and `_`. Create one on the **API keys** page of the app, with a label that names the system that will use it.
* The app shows the full key once, when you create it. Prismlet stores a hash of it and its first 11 characters, `pl_` plus 8, which the app shows so you can tell keys apart. Nobody can show you the full key again. If you lose a key, create a new one and revoke the old one.
* A key belongs to the account, not to a prism or a person. It can run and read every prism and result in the account, including those made in the app.
* There are no scopes and no per-prism keys. To tell systems apart, give each its own key. The **API keys** page shows when each key was last used and the results it made.
## Revoking a key
Revoke a key on the **API keys** page. The next request that uses it answers `401` with `Invalid API key`. A call already waiting on the model when you revoke still finishes and saves its result. Results the key made stay in the account.
To rotate a key without downtime, create the new key, deploy it, check its last use in the app, then revoke the old one.
## Keep keys on your server
Load the key from an environment variable or your secret store, and send requests from your backend. Never put a key in a web page, a mobile app or a repository. Anyone who has it can run your prisms and read your results.
The `/v1` API sends no CORS headers, so a browser blocks any call a web page makes to it. If your front end needs answers, have it call your backend, and let your backend call Prismlet.
## Authentication errors
| Request | Status | `message` |
| ------------------------------------------------ | ------ | ------------------------- |
| No `Authorization` header, or not `Bearer ` | `401` | `Authentication required` |
| A key that doesn't start with `pl_` | `401` | `Invalid API key` |
| An unknown or revoked key | `401` | `Invalid API key` |
The `code` is `unauthorized` in each case. The API checks the path, `format` and body before the key, so a malformed request answers `400` even without a key.
# Sending a text
> The request body, the text shorthand, ref, pinning a version and the size limits.
**POST** /v1/prisms/{prism}/results
One call answers one text. `{prism}` is the prism's slug or id, and the body is JSON:
| Key | Type | Required | What it does |
| ----------------- | ------- | ---------------- | --------------------------------------------------------------------------- |
| `fields` | object | This or `text` | One string per field of the prism version, keyed by field key. |
| `text` | string | This or `fields` | Shorthand for a prism whose only field is `text`. |
| `ref` | string | No | Your reference for the text, returned with the result. 1 to 200 characters. |
| `version` | integer | No | The prism version to answer with. The latest saved version by default. |
| `idempotency_key` | string | No | Makes the call safe to repeat. 1 to 200 characters. |
Send `Content-Type: application/json`. Every key is snake\_case, and the API ignores keys it doesn't know, so a misspelled `idempotencyKey` does nothing and raises no error.
## `fields`
`fields` must hold exactly the field keys of the prism version, no more and no fewer. Every value is a string. Send an empty string for a field you have nothing for.
```json
{
"fields": {
"subject": "Charged twice this month",
"text": "Hi, I was billed twice for September. Please refund the second charge before Friday."
}
}
```
A missing or extra key answers `400` and nothing is saved:
| You send | `message` |
| ----------------------------- | ------------------------------------------------------------------- |
| `fields` without `subject` | `Missing fields: subject` |
| `fields` with an extra `body` | `Unknown fields: body` |
| A number instead of a string | `Invalid request: fields.subject: Expected string, received number` |
## The `text` shorthand
A prism whose only field is `text` also takes the text on its own:
```json
{ "text": "Hi, I was billed twice for September. Please refund the second charge before Friday." }
```
The API turns it into `{"fields": {"text": "..."}}`. Send one of `fields` and `text`. Both, or neither, answers `400` with `Invalid request: fields: send either fields or text`. `support-routing` has two fields, so `{"text": "..."}` answers `400` with `Missing fields: subject`.
## Reference (`ref`)
Put your own id for the text in the reference, `ref`, such as a ticket number. It comes back in the response and in every CSV, TSV and JSON Lines row, so you can match results to your records. It does not have to be unique. Leading and trailing spaces are trimmed.
## Context for one call (`context`)
A prism's context is the same for every call. When part of the background changes per call, such as each client's billing guideline or refund policy, send it as `context`:
```json
{
"text": "Conf with J. Smith re strategy, 1.5h",
"context": "Acme Corp guideline: no charges for internal conferences between firm lawyers."
}
```
The model reads the prism's context, then a line `Context for this call:`, then yours. Say in the prism's context or questions which one wins when they disagree, for example "Where the context for this call conflicts with the rules above, it wins."
* It is optional. An empty or blank value is the same as leaving it out.
* It counts toward the size of the call. See [Size limits](#size-limits).
* It is saved with the result and shown on the result's page in the app. The API does not return it, and no row or download holds it.
* A repeated `idempotency_key` returns the stored result, whatever `context` the repeat sends. A retry sends the stored context again.
## Pinning a version
A call runs the latest saved version of the prism. The response's `version` tells you which one answered.
When someone saves a new version in the app, the next call runs it. That is what you want for a question rewrite. It breaks your integration when the new version adds or removes a field, because your code still sends the old keys. Pin the version to keep the field set steady until your code catches up:
```json
{
"version": 3,
"fields": {
"subject": "Charged twice this month",
"text": "Hi, I was billed twice for September. Please refund the second charge before Friday."
}
}
```
A pinned call sends the field keys of that version. A version that does not exist answers `404` with `Prism version not found`. The Lens is not part of a version, so a pinned call still gets the prism's current Lens in `json`.
## `idempotency_key`
With an `idempotency_key`, repeating the call returns the stored result instead of answering the text again. Use a key derived from your record, such as `tck-8812-1`, and send it on every call you might repeat. [Errors and retries](/guides/errors-and-retries/#idempotency-keys) has the rules, including how unique a key must be.
## Size limits
* Each field value is at most 100,000 characters, enough for a whole call transcript. A longer one answers `400` with `Invalid request: fields.text: String must contain at most 100000 character(s)`.
* The fields, the prism's context, the call's `context` and the longest question must fit in about 26,000 tokens together. Prismlet counts four characters of their JSON as one token, which comes to about 104,000 characters, and the more context and question text the prism has, the less room is left for the fields. Over the budget, the call answers `400` with `Request too large: about 27100 tokens of fields, context and the longest question, the limit is 26000`. Prismlet's estimate is four characters per token, and text that is not English, or has many numbers or symbols, uses more real tokens, so a call close to the budget can still be refused by the model provider and come back as a failed result.
* `context` is at most 100,000 characters. A longer one answers `400` with `Invalid request: context: String must contain at most 100000 character(s)`.
* The whole body is at most 1 MiB. A larger body answers `413`.
Nothing is saved and nothing reaches the model when a call fails one of these checks. [Limits](/reference/limits/) lists every limit in one table.
## Timeouts
The call waits for the answers. Each attempt to reach the model has 15 seconds by default. A `429` or a `5xx` from the model gets one more attempt. A timeout, a network error or any other error from the model fails the call at once. So a call can take about 30 seconds before it fails. Set your client timeout above that. The examples in these docs use 45 seconds.
* curl
```sh
curl --max-time 45 ...
```
* JavaScript
```js
await fetch(url, { ...options, signal: AbortSignal.timeout(45_000) });
```
* Python
```python
requests.post(url, json=body, headers=headers, timeout=45)
```
A call that runs out of time on the model's side is saved as a failed result and answers `502`.
# Formats
> json, raw, csv, tsv and jsonl, what each returns, and when to use which.
All three endpoints take `?format=` and return the result in that format. The default is `json`.
```sh
curl "https://api.prismlet.com/v1/results/0192f5c1-7c1a-7b3e-9f55-3c1d2e4a5b6c?format=raw" \
-H "Authorization: Bearer $PRISMLET_API_KEY"
```
| `format` | `Content-Type` | Body |
| -------- | ------------------------------------------ | ---------------------------------------------------------- |
| `json` | `application/json; charset=utf-8` | The result, with `answers` shaped by the Lens. |
| `raw` | `application/json; charset=utf-8` | The result, with the stored answers and every probability. |
| `csv` | `text/csv; charset=utf-8` | A header row and the result's row. |
| `tsv` | `text/tab-separated-values; charset=utf-8` | The same, separated by tabs. |
| `jsonl` | `application/x-ndjson; charset=utf-8` | The result's row as one line of JSON. |
An empty `format=` counts as left out and returns `json`. An unknown value answers `400`, before anything reaches the model. Errors are JSON in every format, so check the status code before you parse a CSV body.
## `json`
The envelope, with one entry in `answers` per Lens column, in Lens order. Values are keys: option keys, `other`, `insufficient_information`, and `yes`, `no` or `unsure`.
```json
{
"id": "0192f5c1-7c1a-7b3e-9f55-3c1d2e4a5b6c",
"ref": "TCK-8812",
"prism": "support-routing",
"version": 3,
"status": "ok",
"error": null,
"took_ms": 640,
"created_at": "2026-09-23T09:14:02.187Z",
"answers": {
"team": "billing",
"team_probability": 0.91,
"urgent": "yes",
"tone": "annoyed",
"tone_average": 2.05
}
}
```
Use `json` when you want the columns someone chose in the app, named the way they named them.
## `raw`
The same envelope, with `answers` holding the stored answer to every question, keyed by question key. Each answer carries its `type` and every probability.
```json
{
"id": "0192f5c1-7c1a-7b3e-9f55-3c1d2e4a5b6c",
"ref": "TCK-8812",
"prism": "support-routing",
"version": 3,
"status": "ok",
"error": null,
"took_ms": 640,
"created_at": "2026-09-23T09:14:02.187Z",
"answers": {
"team": {
"type": "choice",
"value": "billing",
"probabilities": {
"billing": 0.91,
"technical": 0.05,
"other": 0.03,
"insufficient_information": 0.01
},
"confidence": 0.88
},
"urgent": {
"type": "yes_no",
"probability": 0.83
},
"tone": {
"type": "scale",
"level": "annoyed",
"probabilities": {
"calm": 0.12,
"annoyed": 0.71,
"angry": 0.17
}
}
}
}
```
| `type` | Keys |
| -------- | -------------------------------------------------------------------------------------------------- |
| `choice` | `value` (option key, `other`, `insufficient_information` or `null`), `probabilities`, `confidence` |
| `yes_no` | `probability` of yes |
| `scale` | `level`, `probabilities` |
A Choice's `probabilities` include `other` and `insufficient_information`. The keys of `probabilities` come in no particular order. For the order of options or levels, read the prism in the app.
Use `raw` when a Lens edit must not change what your code reads. A `json` read applies the current Lens, so renaming a column or moving a cutoff changes the next response for every result, old ones included. `raw` answers never change. Apply your own cutoffs to `probability`, and your code decides when they move.
## `csv` and `tsv`
A header row, then the result's row.
format=csv
```txt
result_id,ref,version,status,error,created_at,subject,text,team,team_probability,urgent,tone,tone_average
0192f5c1-7c1a-7b3e-9f55-3c1d2e4a5b6c,TCK-8812,3,ok,,2026-09-23T09:14:02.187Z,Charged twice this month,"Hi, I was billed twice for September. Please refund the second charge before Friday.",Billing,0.91,Yes,Annoyed,2.05
```
The columns are, in this order:
1. `result_id`, `ref`, `version`, `status`, `error`, `created_at`
2. One column per field of the prism's latest version, holding what the call sent
3. The Lens columns
A result made by an older version still gets the latest version's field columns. A field the old version did not have is an empty cell, and a field the latest version dropped is not in the row.
Cells hold labels, as in the app: option labels such as Billing, Other and Not enough information, and Yes, No or Unsure. An `all` column lists every option with its probability, highest first, such as `Billing 0.91; Technical support 0.05; Other 0.03; Not enough information 0.01`. An empty value, such as a `null` `ref`, is an empty cell.
`csv` lines end in CRLF. A cell holding a comma, a double quote or a line break is quoted, with quotes doubled, as RFC 4180 describes. `tsv` lines end in LF, and a tab or line break inside a cell becomes a space.
A text cell that starts with `=`, `+`, `-`, `@`, a tab or a carriage return gets a single quote in front, so a spreadsheet shows it as text instead of running it as a formula. A `ref` of `=SUM(A1)` arrives as `'=SUM(A1)`. Numbers such as probabilities are never changed, and `json`, `raw` and `jsonl` carry the value as sent.
A `csv` row from the API is the same row a download from the app has for that result, so you can append API rows to an exported file.
## `jsonl`
The row as one line of JSON, ending in LF. It has the `csv` columns with keys instead of labels and `all` columns as objects. An empty Lens cell, `ref` or `error` is `null`, and a field the result's version did not have is `""`.
format=jsonl
```txt
{"result_id":"0192f5c1-7c1a-7b3e-9f55-3c1d2e4a5b6c","ref":"TCK-8812","version":3,"status":"ok","error":null,"created_at":"2026-09-23T09:14:02.187Z","subject":"Charged twice this month","text":"Hi, I was billed twice for September. Please refund the second charge before Friday.","team":"billing","team_probability":0.91,"urgent":"yes","tone":"annoyed","tone_average":2.05}
```
Append each response to a file and you have a JSON Lines file of results. [Answering many texts](/guides/many-texts/) does that.
## Keys and labels
| Value | `json`, `raw`, `jsonl` | `csv`, `tsv` |
| ---------------- | ----------------------------------- | ----------------------------- |
| Option | `billing` | Billing |
| Reserved options | `other`, `insufficient_information` | Other, Not enough information |
| Yes / No label | `yes`, `no`, `unsure` | Yes, No, Unsure |
| Probability | `0.91` | `0.91` |
| Empty Lens cell | `null` | empty cell |
| Missing field | `""` in `jsonl` | empty cell |
## A failed result
A failed result has the same shape in every format. The call that made it answers `502`, and `GET /v1/results/{result_id}` answers `200`.
| Format | `answers` or Lens columns | `error` |
| ------------- | ------------------------- | ------------------------ |
| `json`, `raw` | `answers: null` | The error text |
| `csv`, `tsv` | Empty cells | The error text, a cell |
| `jsonl` | `null` | The error text, a string |
A result that succeeded has an empty `error` cell in `csv` and `tsv`, and `null` in the other formats.
502, format=csv
```txt
result_id,ref,version,status,error,created_at,subject,text,team,team_probability,urgent,tone,tone_average
0192f5c3-2b4e-7d10-8a61-5e0f9c7d2a13,TCK-8812,3,failed,"The model took too long to answer, so we stopped after 15 seconds.",2026-09-23T09:15:40.502Z,Charged twice this month,"Hi, I was billed twice for September. Please refund the second charge before Friday.",,,,,
```
# Errors and retries
> Failed results, the retry endpoint, idempotency keys and a call you can repeat safely.
A call to answer a text ends one of three ways:
| Status | Body | Saved |
| -------------- | -------------------------------------------------------------- | ----- |
| `200` | The result, `status: "ok"` | Yes |
| `502` | The result, `status: "failed"` | Yes |
| `4xx` or `500` | An error body with a `code` and a `message` | No |
| `503` | `Service Unavailable`, with no `code`, while a server restarts | No |
A `502` is not an error body. It is the failed result, in the format you asked for, with `answers: null` and an `error` in words:
502 Bad Gateway
```json
{
"id": "0192f5c3-2b4e-7d10-8a61-5e0f9c7d2a13",
"ref": "TCK-8812",
"prism": "support-routing",
"version": 3,
"status": "failed",
"error": "The model took too long to answer, so we stopped after 15 seconds.",
"took_ms": 15012,
"created_at": "2026-09-23T09:15:40.502Z",
"answers": null
}
```
Every other failure has the error body, in JSON whatever `format` you asked for:
400 Bad Request
```json
{
"error": {
"code": "validation_failed",
"message": "Missing fields: subject"
}
}
```
The [errors reference](/reference/errors/) lists every status, `code` and message.
## What to retry
| Status | Retry? | Why |
| ------------------------ | ------ | ---------------------------------------------------------------------------- |
| `400` | No | The request is wrong. Fix it first. |
| `401` | No | The key is missing, wrong or revoked. |
| `404` | No | The prism, version or result does not exist in this account, or is archived. |
| `409` | No | The idempotency key or the retry conflicts. Read the message. |
| `413`, `415` | No | The body is too large or not JSON. Fix it first. |
| `500` | Yes | A bug on our side. Retry a few times with a pause, then stop. |
| `502` | Yes | The model failed. The result is saved and can be retried. |
| `503` | Yes | A server is restarting. Nothing was saved or sent to the model. |
| Timeout or network error | Yes | You can't tell whether the result was saved, so retry with the same key. |
## Retry a failed result
**POST** /v1/results/{result\_id}/retry
The retry endpoint sends a failed result's stored fields to the model again and saves the answer as a new result.
* curl
```sh
curl -X POST https://api.prismlet.com/v1/results/0192f5c3-2b4e-7d10-8a61-5e0f9c7d2a13/retry \
-H "Authorization: Bearer $PRISMLET_API_KEY"
```
* JavaScript
```js
const response = await fetch(
'https://api.prismlet.com/v1/results/0192f5c3-2b4e-7d10-8a61-5e0f9c7d2a13/retry',
{
method: 'POST',
headers: { Authorization: `Bearer ${process.env.PRISMLET_API_KEY}` },
signal: AbortSignal.timeout(45_000),
},
);
const result = await response.json();
```
* Python
```python
import os
import requests
response = requests.post(
"https://api.prismlet.com/v1/results/0192f5c3-2b4e-7d10-8a61-5e0f9c7d2a13/retry",
headers={"Authorization": f"Bearer {os.environ['PRISMLET_API_KEY']}"},
timeout=45,
)
result = response.json()
```
200 OK
```json
{
"id": "0192f5c4-9e27-7c55-b3d8-41a6f0e8c9b2",
"ref": "TCK-8812",
"prism": "support-routing",
"version": 3,
"status": "ok",
"error": null,
"took_ms": 640,
"created_at": "2026-09-23T09:16:05.931Z",
"answers": {
"team": "billing",
"team_probability": 0.91,
"urgent": "yes",
"tone": "annoyed",
"tone_average": 2.05
}
}
```
* The response is the new result, with its own `id`, on `200` or `502`.
* It runs against the exact version and fields of the failed result, even when the prism has newer versions.
* Only a failed result can be retried. Retrying one that succeeded answers `409` with `Only a failed result can be retried.`
* A failed result can be retried once. A second retry answers `409` with `This result was already retried as .`, naming the new result. Read that one, and if it failed too, retry it in turn.
* A result of an archived prism can't be retried. It answers `404` with `This prism is archived, so it cannot answer.`
* An idempotency key held by the failed result moves to the new one.
* Send no body. A `Content-Type: application/json` header with an empty body answers `400` with `Malformed request`.
## Idempotency keys
A timeout on your side doesn't tell you whether Prismlet saved the result. Send an `idempotency_key` and you can repeat the call and still get one result for it.
```json
{
"ref": "TCK-8812",
"fields": {
"subject": "Charged twice this month",
"text": "Hi, I was billed twice for September. Please refund the second charge before Friday."
},
"idempotency_key": "tck-8812-1"
}
```
What a repeat with the same key returns depends on what the key already holds:
| The key holds | The repeat |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Nothing | Answers the text as a normal call. |
| A result that succeeded | Returns that result with `200` without asking the model. `fields`, `version` and `ref` in the new body are ignored. |
| A result that failed | Retries it, as the retry endpoint does, and returns the new result on `200` or `502`. The key moves to the new result. |
| A result of another prism | Answers `409` with `This idempotency key was already used for another prism.` |
* A repeat of a failed result retries the fields stored with it, not the fields in your new body. If you want a changed text answered, use a new key.
* Because the key moves on every retry, repeating a call after a `502` until it answers `200` is safe, and the key ends up on one successful result.
* Two calls with the same key at the same moment both get the one stored result, though both may be sent to the model.
* Keys are unique across the whole account and shared with the app, whose file runs use keys of their own. Derive yours from your record, such as `tck-8812-1`, and change the suffix when you want the record answered again.
* The prism is still checked first, so a repeat against an archived prism answers `404`. The body is still validated, so a repeat with a malformed body answers `400`.
## A call you can repeat safely
The JavaScript and Python versions send a call with an idempotency key and repeat it on a timeout, a network error, a `500` or a `502`, waiting 1, 2 and then 4 seconds. Every other status comes straight back. The body must carry an `idempotency_key`, or a repeat can answer the text twice.
* curl
```sh
curl https://api.prismlet.com/v1/prisms/support-routing/results \
--fail --max-time 45 --retry 3 --retry-connrefused \
-H "Authorization: Bearer $PRISMLET_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"ref": "TCK-8812",
"fields": {
"subject": "Charged twice this month",
"text": "Hi, I was billed twice for September. Please refund the second charge before Friday."
},
"idempotency_key": "tck-8812-1"
}'
```
`--retry` repeats after a timeout, a `500` or a `502`, among others, and doubles its wait each time from one second. With `--fail`, curl prints only the successful attempt's body, and for a `4xx` or a last `5xx` it prints the status on stderr instead. It does not repeat a connection that drops in the middle of a call. `--retry-all-errors`, in curl 7.71 or later, repeats that too, but it also repeats every `4xx`.
* JavaScript
answer.mjs
```js
const API = 'https://api.prismlet.com/v1';
const RETRYABLE = new Set([500, 502, 503]);
async function answer(prism, body, attempts = 4) {
for (let attempt = 1; ; attempt += 1) {
try {
const response = await fetch(`${API}/prisms/${prism}/results`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PRISMLET_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(45_000),
});
if (!RETRYABLE.has(response.status) || attempt === attempts) {
return { status: response.status, result: await response.json() };
}
} catch (error) {
// fetch throws on a network error or the timeout.
if (attempt === attempts) throw error;
}
await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** (attempt - 1)));
}
}
const { status, result } = await answer('support-routing', {
ref: 'TCK-8812',
fields: {
subject: 'Charged twice this month',
text: 'Hi, I was billed twice for September. Please refund the second charge before Friday.',
},
idempotency_key: 'tck-8812-1',
});
console.log(status, result);
```
* Python
answer.py
```python
import os
import time
import requests
API = "https://api.prismlet.com/v1"
RETRYABLE = {500, 502, 503}
def answer(prism, body, attempts=4):
for attempt in range(1, attempts + 1):
try:
response = requests.post(
f"{API}/prisms/{prism}/results",
headers={"Authorization": f"Bearer {os.environ['PRISMLET_API_KEY']}"},
json=body,
timeout=45,
)
if response.status_code not in RETRYABLE or attempt == attempts:
return response.status_code, response.json()
except requests.RequestException:
# A network error or the timeout.
if attempt == attempts:
raise
time.sleep(2 ** (attempt - 1))
status, result = answer(
"support-routing",
{
"ref": "TCK-8812",
"fields": {
"subject": "Charged twice this month",
"text": "Hi, I was billed twice for September. Please refund the second charge before Friday.",
},
"idempotency_key": "tck-8812-1",
},
)
print(status, result)
```
After the last attempt you may still hold a `502`. The failed result is saved, so run the same call later with the same key and it retries the result then.
# Answering many texts
> Answer a list of texts a few calls at a time, and resume a run that stopped.
There is no batch endpoint. Each call answers one text, so a list of texts is a loop of calls. The app's own file runs work this way too. They send eight rows at a time, each with its own idempotency key.
The loop needs three things:
* **A few calls at a time.** Start with three in flight. The API has no rate limit, but each call stays open until the model answers, which can take up to about 30 seconds.
* **A client timeout above 30 seconds.** [Sending a text](/guides/sending-a-text/#timeouts) explains why. The examples use 45 seconds.
* **An idempotency key per record.** Run the same list again and every record that already succeeded comes back from storage without asking the model, while every failed one is retried. A run that stopped halfway resumes where it left off.
## The loop
Each example reads `tickets.json`, sends each ticket with `format=jsonl`, and writes the rows to `answers.jsonl`. With `jsonl`, every response is one line of JSON, so the output file is the responses joined together.
tickets.json
```json
[
{
"id": "8812",
"subject": "Charged twice this month",
"body": "Hi, I was billed twice for September. Please refund the second charge before Friday."
},
{
"id": "8813",
"subject": "Calendar sync stopped",
"body": "Since this morning none of our appointments show up in Google Calendar."
}
]
```
* curl
The shell version needs `jq` to turn `tickets.json` into one request body per line, then `xargs` runs three `curl` calls at a time.
```sh
jq -c '.[] | {
ref: ("TCK-" + .id),
idempotency_key: ("tck-" + .id + "-1"),
fields: { subject: .subject, text: .body }
}' tickets.json |
tr '\n' '\0' |
xargs -0 -n 1 -P 3 curl -sS --fail --max-time 45 \
-H "Authorization: Bearer $PRISMLET_API_KEY" \
-H "Content-Type: application/json" \
"https://api.prismlet.com/v1/prisms/support-routing/results?format=jsonl" \
-d > answers.jsonl
```
`xargs` puts each body after `-d`. With `--fail`, only answered rows reach `answers.jsonl`, and curl names each failing status on stderr. Run the same command again to fill the gaps. Answered tickets come back from storage and failed ones are retried. A `400` repeats on every run until you fix that ticket.
* JavaScript
answer-all.mjs
```js
import { readFile, writeFile } from 'node:fs/promises';
const API = 'https://api.prismlet.com/v1';
const CONCURRENCY = 3;
const tickets = JSON.parse(await readFile('tickets.json', 'utf8'));
async function answer(ticket) {
const response = await fetch(`${API}/prisms/support-routing/results?format=jsonl`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PRISMLET_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
ref: `TCK-${ticket.id}`,
idempotency_key: `tck-${ticket.id}-1`,
fields: { subject: ticket.subject, text: ticket.body },
}),
signal: AbortSignal.timeout(45_000),
});
const text = await response.text();
// 200 and 502 both carry the result's row; any other status is an error body.
if (response.status !== 200 && response.status !== 502) {
throw new Error(`${response.status} ${text}`);
}
return text;
}
// Rows go in input order, whatever order the calls finish in.
const rows = new Array(tickets.length).fill('');
const problems = [];
let next = 0;
async function worker() {
while (next < tickets.length) {
const index = next++;
const ticket = tickets[index];
try {
rows[index] = await answer(ticket);
} catch (error) {
problems.push({ id: ticket.id, error: String(error) });
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
await writeFile('answers.jsonl', rows.join(''));
console.log(`${tickets.length - problems.length} rows, ${problems.length} problems`, problems);
```
* Python
answer\_all.py
```python
import json
import os
from concurrent.futures import ThreadPoolExecutor
import requests
API = "https://api.prismlet.com/v1"
CONCURRENCY = 3
with open("tickets.json", encoding="utf-8") as f:
tickets = json.load(f)
def answer(ticket):
response = requests.post(
f"{API}/prisms/support-routing/results",
params={"format": "jsonl"},
headers={"Authorization": f"Bearer {os.environ['PRISMLET_API_KEY']}"},
json={
"ref": f"TCK-{ticket['id']}",
"idempotency_key": f"tck-{ticket['id']}-1",
"fields": {"subject": ticket["subject"], "text": ticket["body"]},
},
timeout=45,
)
# 200 and 502 both carry the result's row; any other status is an error body.
if response.status_code not in (200, 502):
raise RuntimeError(f"{response.status_code} {response.text}")
return response.text
rows, problems = [], []
with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
futures = [(ticket, pool.submit(answer, ticket)) for ticket in tickets]
for ticket, future in futures:
try:
rows.append(future.result())
except Exception as error:
problems.append({"id": ticket["id"], "error": str(error)})
with open("answers.jsonl", "w", encoding="utf-8", newline="") as f:
f.write("".join(rows))
print(f"{len(rows)} rows, {len(problems)} problems", problems)
```
## Reading the output
Each line of `answers.jsonl` is one row: `result_id`, `ref`, `version`, `status`, `error`, `created_at`, the fields, then the Lens columns. A row with `"status": "failed"` carries its `error` and has `null` Lens columns. Run the script again to retry those. Their keys retry them, and the rows that were already answered come back unchanged, without asking the model.
`problems` holds calls that got no row: a status other than `200` or `502`, a timeout or a network error. Running again fixes timeouts, network errors and `500`s. A `400`, such as a text over 100,000 characters, repeats until you fix the record. The JavaScript and Python versions write rows in the order of `tickets.json`, and the curl version writes them as calls finish.
If you'd rather have a spreadsheet, use `format=csv` and keep only the first response's header row.
## What a key stands for
A key stands for one answer to one record. The examples use `tck--1`. If a ticket's text changes and you want it answered again, change the suffix to `-2`. The old key would return the old answer.
Don't reuse a key scheme between prisms. [Errors and retries](/guides/errors-and-retries/#idempotency-keys) explains why.
## Running a file from the app
The Try tab of a prism can run a whole file without any code.
* The file is CSV, TSV or JSON, told apart by its extension. A `.txt` file is read as TSV or CSV, whichever it looks like. In CSV and TSV the first row is the column names. A JSON file is an array of objects, and its keys are the column names.
* A file holds at most 10,000 rows and 200 MiB. Blank lines are skipped. A file that is not valid UTF-8 is read as Windows-1252. Keep the tab open while it runs; a large file of long texts can take most of an hour.
* You choose a column for every field, and optionally one for the reference. Without one, or where its cell is blank, the reference is the row number.
* Optionally, a column for the [context](/guides/sending-a-text/#context-for-one-call-context). Each row's cell is sent as that call's context, after the prism's own. An empty cell sends none.
* Each row is saved as a result with the file as its source, so it shows in Results like any other.
* Running the same file again with the same columns chosen, on the same prism version, resumes: answered rows come back from storage and failed ones are retried. Choosing other field, reference or context columns, or saving a new version of the prism, answers every row again.
* The answered file is the original columns, then the Lens columns, then `result_id`, in the file's own format. CSV and TSV cells hold labels. A JSON file holds keys, and every original value comes back as a string.
* A Lens column or `result_id` that has the same name as an original column gets a number suffix, such as `urgent_2`, so your own columns are kept.
* A row the app could not send, for example one with a reference over 200 characters, is counted as not saved and has no result.
## Checking answers against your own labels
If the file already holds the answers you expect, from your QA team for example, choose that column under **Expected answers** for each question you want checked. After the run the app shows how often its answers match yours.
* Yes / No cells can be `yes`, `no`, `y`, `n`, `true`, `false`, `1` or `0`. Choice cells can be an option's key or label, including Other and Not enough information. Scale cells can be a level's key, its label or its number counting from 1. Case and surrounding spaces don't matter.
* A blank cell leaves that row out of the check. Any other value the app can't read is left out and counted, with a few examples shown.
* Yes / No shows agreement, recall and precision for Yes at cutoffs from 0.1 to 0.9, plus a row for the Lens's own cutoffs, which also shows the share in the Unsure band. Unsure rows are left out of the Lens row's agreement, recall and precision, and they are listed as misses.
* Choice shows agreement, precision and recall per option, and agreement when only answers with a top probability from 0.5 to 0.9 are counted, with the share of rows that leaves.
* Scale shows exact agreement and agreement within one level.
* **Group results by** splits the headline figures by a column, such as language, site or team. A Yes / No group also shows its share in the Unsure band, which its agreement leaves out.
* Every row where the answers differ is listed as a miss. **Download evaluation** saves the figures as CSV, and **Download misses** saves every miss with its reference and `result_id`.
* Your expected answers and the group column stay in the browser. Only the field columns, and the context column when you choose one, are sent.
* Changing the expected-answer or group columns after a run updates the figures without asking the model again. To compare two versions of the prism, run the file once on each.
# Errors
> Every status, error code and message the API returns, and what to do about each.
Every error has this JSON body, whatever `format` you asked for, except the `503` a restarting server sends, listed in the table below:
404 Not Found
```json
{
"error": {
"code": "not_found",
"message": "Prism not found"
}
}
```
Branch on the status and `code`. The `message` is for people. A validation message names the paths that failed and never repeats a value you sent. `Unknown fields` lists the keys you sent.
A failed result is not an error. It answers `502` with the result itself, as [Errors and retries](/guides/errors-and-retries/) shows.
## Every error
| Status and `code` | `message` | What to do |
| ------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `400` `validation_failed` | `Invalid request: : ` | Fix the named path. Several problems are joined with `; `. |
| `400` `validation_failed` | `Missing fields: subject` | Send every field key of the prism version. |
| `400` `validation_failed` | `Unknown fields: body` | Send only the field keys of the prism version. |
| `400` `validation_failed` | `Request too large: about 27100 tokens of fields, context and the longest question, the limit is 26000` | Shorten the fields or the call's context. See [Limits](/reference/limits/). |
| `400` `bad_request` | `Malformed request` | The body is not valid JSON, or has a JSON `Content-Type` and no body. |
| `401` `unauthorized` | `Authentication required` | Send `Authorization: Bearer `. |
| `401` `unauthorized` | `Invalid API key` | The key is unknown, revoked or not a Prismlet key. Create a key in the app. |
| `404` `not_found` | `Prism not found` | No prism with that slug or id in this account, or it is archived. |
| `404` `not_found` | `Prism version not found` | The `version` you pinned does not exist. |
| `404` `not_found` | `Result not found` | No result with that id in this account. |
| `404` `not_found` | `This prism is archived, so it cannot answer.` | A retry of a result whose prism is archived. Archived prisms answer nothing. |
| `404` `not_found` | `Route not found` | Check the path and method. A browser's CORS preflight gets this too. |
| `409` `conflict` | `This idempotency key was already used for another prism.` | Use a different key for this prism. |
| `409` `conflict` | `Only a failed result can be retried.` | The result succeeded. Read it instead. |
| `409` `conflict` | `This result was already retried as .` | Read the named result, and retry that one if it failed too. |
| `413` `bad_request` | `Malformed request` | The body is over 1 MiB. |
| `415` `bad_request` | `Malformed request` | Send the body as `application/json`. On `POST /v1/prisms/{prism}/results` a `text/plain` body answers `400` instead. |
| `500` `internal_error` | `Internal server error` | A bug on our side. Retry with a pause. With an idempotency key the repeat is safe. |
| `503`, no `code` | `Service Unavailable` | A server is restarting and took nothing. Retry with a pause. The body is `{"error":"Service Unavailable","message":"Service Unavailable","statusCode":503}`. |
A `4xx` error saves nothing. It never reaches the model either, with one exception, because the result is saved in a second step after the model answers: when two first calls with the same key race on different prisms, the one that loses answers `409` after it was sent. A key revoked while the model is answering still gets that call's result, and its next call answers `401`.
### Validation messages
An `Invalid request` message lists each failing path with the problem. The server's own messages:
| Request | `message` |
| --------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Both `fields` and `text`, or neither | `Invalid request: fields: send either fields or text` |
| A field over 100,000 characters | `Invalid request: fields.text: String must contain at most 100000 character(s)` |
| A field value that is not a string | `Invalid request: fields.subject: Expected string, received number` |
| `version` that is not a whole number | `Invalid request: version: Expected integer, received float` |
| `version` over 2147483647 | `Invalid request: version: Number must be less than or equal to 2147483647` |
| `idempotency_key` over 200 characters | `Invalid request: idempotency_key: String must contain at most 200 character(s)` |
| A NUL or unpaired surrogate character in any string | `Invalid request: fields.text: must not contain NUL or unpaired surrogate characters` |
| An unknown `format` | `Invalid request: format: Expected 'json' \| 'raw' \| 'csv' \| 'tsv' \| 'jsonl'` |
| A result id that is not a UUID | `Invalid request: result_id: Invalid uuid` |
Keys the API doesn't know are ignored, not rejected, so a misspelled optional key such as `idempotencyKey` does nothing.
## The order of checks
When a request has several problems, the first check that fails decides the answer. For `POST /v1/prisms/{prism}/results`:
1. The body is parsed, then the path, `format` and body are validated: `400`, `413` or `415`.
2. The API key: `401`.
3. The prism: `404`.
4. The idempotency key: `409`. A stored result that succeeded comes back and the call stops here. One that failed is retried with its stored fields and version, so steps 5 and 6 are skipped, but the size estimate still runs.
5. The version: `404`.
6. The field keys, then the size estimate: `400`.
7. The model: `200` or `502`.
A request with a bad body and no key answers `400`, not `401`. A repeat of an idempotency key returns the stored result, or retries it with its stored fields and version, so the `version` and `fields` in the body don't matter.
For `POST /v1/results/{result_id}/retry` the order is the body, which must be absent, valid JSON or plain text, and is ignored (`400`, `413` or `415`), then the path and `format` (`400`), the key (`401`), the result (`404`), whether it failed and whether it was retried (`409`), whether its prism is archived (`404`), the size estimate (`400`), then the model. `GET /v1/results/{result_id}` stops after the result and answers `200`.
# Limits
> Every limit on a call and on a prism, and what the API does not have.
## Calls
Lengths count UTF-16 code units, as JavaScript's `string.length` does, so an emoji can count as two characters. No string you send may contain a NUL character or an unpaired surrogate, and one that does answers `400` `validation_failed`.
| Limit | Value | Over the limit |
| ------------------------------------------ | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Texts per call | 1 | Make one call per text. See [Answering many texts](/guides/many-texts/). |
| Field value, or `text` | 100,000 characters | `400` `validation_failed` |
| `context` | 100,000 characters | `400` `validation_failed` |
| Fields, both contexts and longest question | About 26,000 tokens, counted as 4 characters of JSON each | `400` `Request too large: about N tokens of fields, context and the longest question, the limit is 26000` |
| Request body | 1 MiB | `413` `Malformed request` |
| `ref` | 1 to 200 characters, after trimming | `400` `validation_failed` |
| `idempotency_key` | 1 to 200 characters, after trimming, unique in the account | `400` `validation_failed`, or `409` when first used with another prism |
| `version` | A whole number from 1 to 2,147,483,647 | `400` `validation_failed`, or `404` when the version doesn't exist |
| `{prism}` in the path | 1 to 64 characters, a slug or a UUID | `400` `validation_failed`, or `404` when no such prism |
| `{result_id}` in the path | A UUID | `400` `Invalid request: result_id: Invalid uuid` |
| Model time | 15 seconds per attempt by default. A second attempt only after a `429` or a `5xx` | A failed result, `502` |
| Retries of one failed result | 1. See [Errors and retries](/guides/errors-and-retries/#retry-a-failed-result). | `409` `This result was already retried as .` |
## Prisms
You set these in the app when you build a prism. They explain what a call can send and get back.
| Limit | Value |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Fields | 1 to 8 |
| Field, question and column keys | A lowercase letter, then up to 63 lowercase letters, digits or `_` |
| Reserved keys | `context` for fields. `result_id`, `ref`, `version`, `status`, `error` and `created_at` for field, question and column keys. `other` and `insufficient_information` for Choice option keys |
| Context | Up to 8,000 characters |
| Questions | 1 to 100 |
| Question text | Up to 500 characters, plus up to 4,000 of instructions |
| Choice options | 2 to 253, plus `other` and `insufficient_information` |
| Scale levels | 2 to 10 |
| Option label | Up to 200 characters, plus up to 2,000 of description |
| Whole prism | About 16,000 tokens |
| Lens columns | 1 to 200 |
## What the API does not have
* **Rate limits.** The API does not limit calls per key or per account. Keep a few calls in flight, as [Answering many texts](/guides/many-texts/) shows, rather than hundreds.
* **A batch endpoint.** Each call answers one text. Loop with bounded concurrency and an idempotency key per record.
* **Webhooks and async jobs.** The call waits for the answers, so there is nothing to poll or receive. Set a client timeout above 30 seconds, as [Sending a text](/guides/sending-a-text/#timeouts) explains.
* **Listing results.** You read a result by its `id`. Keep the ids you need, match results to records with `ref`, or look results up in the app.
* **SDKs.** Call the API over HTTP. To generate a client, use the OpenAPI 3.1 document at [`/openapi.json`](/openapi.json).
* **Key scopes.** A key works for every prism in its account. Give each system its own key, so you can tell their calls apart and revoke one without the others.
* **CORS.** Browsers can't call `/v1`. Call it from your server, as [Authentication](/guides/authentication/#keep-keys-on-your-server) explains.
* **A stability promise, yet.** Prismlet is not live. Until launch, `/v1` can change without notice.
* **Credits and billing.** Responses carry no cost, and no call is refused for payment.