# Introduction layahost is a hosted API for typed decisions. You send a piece of text and a question with a fixed set of answers. You get back one of those answers, with probabilities, as JSON your code can branch on. It runs **Laya**, the open-source decision model by Convai Innovations (Apache-2.0), and implements the wire format of TypeSafe AI's Jev System One API. Code written for Jev, including the official Jev SDKs, works after you change the base URL and the API key. layahost is an unofficial hosted service. It is not affiliated with, endorsed by or sponsored by Convai Innovations or TypeSafe AI. ## How it works Every question has a type, and every answer is typed to match: | Type | You define | You get back | | --- | --- | --- | | `choice` | 2 or more named options, optionally with a description each | The chosen option and a probability for every option | | yes/no (`noul` in Jev) | A yes/no question | The probability that the answer is yes | | `score` | 2 to 10 ordered levels, lowest first | The expected level and a probability for every level | Laya is a classifier, not a text generator: it never writes free text, so every answer is one of the options you defined. There is nothing to parse and no malformed output to retry. Server-side processing typically takes tens of milliseconds, and every response reports its own `latency_ms`. Typical uses are content moderation, spam filtering, support-ticket intent and routing, sentiment and urgency scoring. Accuracy depends on your data and on how you word the question, so test on a sample of real traffic before you rely on it. ## Two endpoints | Endpoint | Use it when | | --- | --- | | [`POST /v1/decide`](https://layahost.com/docs/decide) | You want one decision about one text. Use a ready-made [template](https://layahost.com/docs/templates) (moderation, spam, routing and more) or your own question. The response is flat and easy to read. | | [`POST /v1/systemone`](https://layahost.com/docs/systemone) | You want the Jev-compatible API: up to 32 questions about the same text in one request, and the official Jev SDKs for Python and JavaScript. | ## Quickstart 1. [Create an account](https://layahost.com/register) and add credit on the [Billing](https://layahost.com/billing) page. Verifying your email adds $1 of welcome credit (200K decisions), enough to try everything. The smallest top-up is $10, which buys 2,000,000 decisions. 2. Create a key on the [API keys](https://layahost.com/keys) page and store it as `LAYAHOST_API_KEY`. The key is shown only once. 3. Send your first request: POST /v1/decide ```bash curl https://layahost.com/v1/decide \ -H "Authorization: Bearer $LAYAHOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "I was charged twice this month, please refund one payment.", "template": "support_intent" }' ``` POST /v1/decide ```javascript const res = await fetch("https://layahost.com/v1/decide", { method: "POST", headers: { Authorization: `Bearer ${process.env.LAYAHOST_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ text: "I was charged twice this month, please refund one payment.", template: "support_intent", }), }); const result = await res.json(); console.log(result.decision, result.confidence); ``` POST /v1/decide ```python import os import requests res = requests.post( "https://layahost.com/v1/decide", headers={"Authorization": f"Bearer {os.environ['LAYAHOST_API_KEY']}"}, json={ "text": "I was charged twice this month, please refund one payment.", "template": "support_intent", }, timeout=10, ) res.raise_for_status() result = res.json() print(result["decision"], result["confidence"]) ``` POST /v1/decide ```php $client = new GuzzleHttp\Client(['base_uri' => 'https://layahost.com']); $res = $client->post('/v1/decide', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('LAYAHOST_API_KEY')], 'json' => [ 'text' => 'I was charged twice this month, please refund one payment.', 'template' => 'support_intent', ], ]); $result = json_decode($res->getBody(), true); echo $result['decision'], ' ', $result['confidence'], PHP_EOL; ``` The response names the decision, how sure the model is, and what the request cost: Response · 200 ```json { "decision": "refund", "confidence": 0.9902, "probabilities": { "refund": 0.9979, "cancellation": 0.0006, "other": 0.0005, "bug_report": 0.0004, "feature_request": 0.0003, "question": 0.0002, "praise": 0.0002 }, "template": "support_intent", "model": "laya-english", "usage": { "decisions": 1, "input_tokens": 91, "cost_micros": 5, "cost_usd": 5.0e-6 }, "latency_ms": 207.74, "request_id": "req_iauupwcy6zdiagjozndye5ls" } ``` You can also try templates and your own questions in the [Playground](https://layahost.com/playground) without writing code. Playground runs are billed like API calls. ## Base URL All endpoints live under `https://layahost.com/v1`, with no `/api` prefix, exactly like the Jev API. Requests and responses are JSON, so send `Content-Type: application/json` with every `POST`. | Method | Path | Description | | --- | --- | --- | | `POST` | [`/v1/decide`](https://layahost.com/docs/decide) | One decision about one text, from a template or your own question | | `GET` | [`/v1/templates`](https://layahost.com/docs/templates) | List the ready-made templates | | `POST` | [`/v1/systemone`](https://layahost.com/docs/systemone) | Jev-compatible: many typed questions about one state | | `GET` | [`/v1/models`](https://layahost.com/docs/models) | List the available models | ## Next steps - [Authentication](https://layahost.com/docs/authentication): create and use API keys. - [Migrate from Jev](https://layahost.com/docs/migrate-from-jev): switch existing Jev code in two lines. - [POST /v1/decide](https://layahost.com/docs/decide): the full request and response reference. - [Pricing & billing](https://layahost.com/docs/pricing): $5 per million decisions, prepaid. --- # Authentication Every API request is authenticated with an API key, sent as a bearer token in the `Authorization` header. ## Create a key 1. Open [API keys](https://layahost.com/keys) in the console and choose **Create key**. 2. Name it after where it runs, for example `production` or `support-bot`. 3. Copy the key. It starts with `lh_` and is shown only once. We store only a hash of each key, so we cannot show it again. If you lose a key, create a new one and revoke the old one. You can have up to 25 active keys per account. ## Send the key Pass the key in the `Authorization` header on every request: Header ```http Authorization: Bearer lh_... ``` For example, to list the available models: GET /v1/models ```bash curl https://layahost.com/v1/models \ -H "Authorization: Bearer $LAYAHOST_API_KEY" ``` The official Jev SDKs read the key from the `TYPESAFE_API_KEY` environment variable. Put your layahost key there and point the SDK at layahost, as described in [Migrate from Jev](https://layahost.com/docs/migrate-from-jev). ## Keep keys secret - Call the API from your server, never from a browser or a mobile app, where anyone can read the key. The JavaScript SDK refuses to run in a browser unless you set `dangerouslyAllowBrowser`. - Load keys from environment variables or a secrets manager, not from source code. - Use one key per environment or service. Usage is tracked per key, and you can revoke one without touching the others. ## Revoke a key Revoke a key on the [API keys](https://layahost.com/keys) page. It stops working immediately: requests that use it get a `401`. Revoking cannot be undone. ## Authentication errors A request without a valid key gets `401 Unauthorized` with `error_type` set to `authentication_error`. These requests are not billed and do not count towards your [rate limit](https://layahost.com/docs/rate-limits). No `Authorization` header: Response · 401 ```json { "detail": { "error_type": "authentication_error", "message": "Missing API key. Send it as \"Authorization: Bearer \"." } } ``` A key that does not exist or has been revoked: Response · 401 ```json { "detail": { "error_type": "authentication_error", "message": "Invalid or revoked API key." } } ``` See [Errors](https://layahost.com/docs/errors) for every error type. --- # Migrate from Jev layahost implements `POST /v1/systemone` and `GET /v1/models` with the same requests, responses and errors as TypeSafe AI's Jev API. Existing code keeps working after you change two settings: the base URL and the API key. ## 1. Create a layahost key Sign up, add credit on the [Billing](https://layahost.com/billing) page, and create a key on the [API keys](https://layahost.com/keys) page. See [Authentication](https://layahost.com/docs/authentication). ## 2. Point your client at layahost Both official SDKs, `typesafe-sdk` for Python and `@typesafe-ai/sdk` for JavaScript, read their configuration from the same environment variables. Set them and run your code unchanged: Environment ```bash export TYPESAFE_BASE_URL=https://layahost.com export TYPESAFE_API_KEY=lh_... ``` Or pass the same settings to the client constructor: `base_url` in Python, `baseURL` in JavaScript. Jev SDK on layahost ```python import os from typesafe_sdk import Choice, TypeSafeClient with TypeSafeClient( base_url="https://layahost.com", api_key=os.environ["LAYAHOST_API_KEY"], ) as client: response = client.system_one( state={"document": "I was charged twice. Please fix this ASAP."}, questions={ "category": Choice( instructions="What is this ticket about?", criteria={"billing": None, "technical": None, "other": None}, ), }, ) print(response.choices["category"].choice) ``` Jev SDK on layahost ```javascript import { choice, TypeSafeClient } from "@typesafe-ai/sdk"; const client = new TypeSafeClient({ baseURL: "https://layahost.com", apiKey: process.env.LAYAHOST_API_KEY, }); const response = await client.systemOne({ state: { document: "I was charged twice. Please fix this ASAP." }, questions: { category: choice("What is this ticket about?", { billing: null, technical: null, other: null, }), }, }); console.log(response.answers.category.choice); ``` If you call the HTTP API directly, replace `https://api.typesafe.ai` with `https://layahost.com`. The paths stay the same. Tested with typesafe-sdk 0.7.1 for Python and @typesafe-ai/sdk 0.6.0 for JavaScript: system_one / systemOne with all three question types, models.list() , request IDs, and the SDKs' authentication and validation errors. ## 3. Check your thresholds layahost answers with Laya, a different model from Jev. Answers have the same shape, but the probabilities will not match what Jev returned. If your code compares a probability or `noul` value with a threshold, re-check that threshold on a sample of your own data before you switch production traffic. ## What stays the same - Paths: `POST /v1/systemone` and `GET /v1/models`, with no `/api` prefix. - The request body: `state`, `model` and a `questions` map with `noul`, `choice` and `score` questions. `instructions` is optional, as in Jev. - The answer fields: `choice` and `probabilities`; `noul`; `score`, `legend` and `probabilities`; plus `usage`. - Model names: `jev-latest`, the SDKs' default, and any other `jev-*` name are accepted and answered by `laya-auto`. - Request IDs: every response has an `x-typesafe-request-id` header, so `response.request_id` in Python and `requestId` in JavaScript work as usual. The same ID is in `x-request-id`, in `meta.request_id` and in [Logs](https://layahost.com/logs). - Errors: `{"detail": {"error_type": ..., "message": ...}}`, and FastAPI-style lists for `422`. The SDKs raise their usual exception classes, and their automatic retries on `429` and `5xx` honour our `retry-after` header. ## What is different | Area | On layahost | | --- | --- | | Response `model` | Names the Laya checkpoint that answered, `laya-english` or `laya-multilingual`, even when you ask for `jev-latest`. Update any code that checks this field. See [Models](https://layahost.com/docs/models). | | Extra response fields | A `meta` object (checkpoint, cache hit, latency, request ID, decisions, and the cost as `cost_micros` and `cost_usd`), and `confidence` on yes/no answers. The SDKs ignore fields they don't know. | | Extra request fields | `lang` (a language hint) and `cache` (default `true`). Both are optional. See [POST /v1/systemone](https://layahost.com/docs/systemone). | | `usage.output_tokens` | Always `0`: Laya picks between your options and generates no text. | | Limits | Up to 32 questions per request, 32,000 characters of state and 2 to 10 levels per `score` question. Jev allows up to 255 options per `choice` question; on layahost all option names and descriptions of one question must fit in 192 tokens, roughly 100 to 150 short options, or you get `422`. The rate limit is 600 requests per minute per key. | | Billing | Prepaid credit at $5 per million decisions, where one decision is one answered question. An empty balance returns `402` with `error_type` `insufficient_credits`. See [Pricing & billing](https://layahost.com/docs/pricing). | ## Read request IDs and meta with the SDKs The SDKs' own request-ID accessors work, on responses and on errors (`request_id` in Python, `requestId` in JavaScript). Quote the ID when you [contact support](mailto:support@layahost.com). The `meta` object is a layahost extension that the SDKs don't model, but you can read it from the raw response: Request ID and meta ```python from typesafe_sdk import Noul, TypeSafeClient with TypeSafeClient() as client: response = client.system_one( state="Where is my parcel? It was due on Monday.", questions={"shipping": Noul(instructions="Is this about a delivery?")}, ) print(response.request_id) print(response.raw_http_response.json()["meta"]) # layahost extension ``` Request ID and meta ```javascript import { noul, TypeSafeClient } from "@typesafe-ai/sdk"; const client = new TypeSafeClient(); const { data, requestId } = await client .systemOne({ state: "Where is my parcel? It was due on Monday.", questions: { shipping: noul("Is this about a delivery?") }, }) .withResponse(); console.log(requestId); console.log(Reflect.get(data, "meta")); // layahost extension, not in the SDK's types ``` ## Send layahost extensions Pass `lang` or `cache` through the SDKs like this. In Python, use `extra_body`. In JavaScript, extra properties on the request object are forwarded as they are; build the request as a variable so TypeScript accepts the extra fields. lang and cache ```python from typesafe_sdk import Noul, TypeSafeClient with TypeSafeClient() as client: response = client.system_one( model="laya-auto", state="Ich möchte mein Abo kündigen.", questions={"cancel": Noul(instructions="Does the customer want to cancel?")}, extra_body={"lang": "de", "cache": False}, ) print(response.nouls["cancel"].noul) ``` lang and cache ```javascript import { noul, TypeSafeClient } from "@typesafe-ai/sdk"; const client = new TypeSafeClient(); const request = { model: "laya-auto", state: "Ich möchte mein Abo kündigen.", questions: { cancel: noul("Does the customer want to cancel?") }, lang: "de", cache: false, }; const response = await client.systemOne(request); console.log(response.answers.cancel.noul); ``` ## Handle a low balance The Jev SDKs have no special class for `402`. Python raises `TypeSafeAPIError` and JavaScript throws `APIError`, both with `status` set to `402`. Don't retry these: [top up](https://layahost.com/billing) instead. We also email you when your balance runs low; set the threshold in Settings → Data & alerts. See [Errors](https://layahost.com/docs/errors). --- # Perspective API replacement Google is shutting down the Perspective API on 31 December 2026. layahost implements the same `comments:analyze` method, request and response, so existing code keeps working after you change the host and the API key. Endpoints ```http POST https://layahost.com/v1alpha1/comments:analyze POST https://layahost.com/v1alpha1/comments:suggestscore POST https://layahost.com/batch GET https://layahost.com/$discovery/rest?version=v1alpha1 ``` layahost is not affiliated with Google or Jigsaw. The scores come from a different model, the open Laya model, calibrated so that they read like Perspective's. Check your thresholds on your own comments before you switch; see [Accuracy](#accuracy). ## Migrate Using Coral, HELM, lm-evaluation-harness or another tool with Perspective built in? See [Perspective integrations](https://layahost.com/docs/perspective-integrations) for the exact setting. 1. [Create an account](https://layahost.com/register) and create a key on the [API keys](https://layahost.com/keys) page. 2. Replace `commentanalyzer.googleapis.com` with `layahost.com` and your Google API key with the layahost key. The key can stay in `?key=`; `X-Goog-Api-Key` and `Authorization: Bearer` work too. comments:analyze ```python from googleapiclient import discovery client = discovery.build( "commentanalyzer", "v1alpha1", developerKey=LAYAHOST_API_KEY, # was: https://commentanalyzer.googleapis.com/$discovery/rest?version=v1alpha1 discoveryServiceUrl="https://layahost.com/$discovery/rest?version=v1alpha1", static_discovery=False, ) response = client.comments().analyze(body={ "comment": {"text": "You are an idiot and nobody wants you here."}, "requestedAttributes": {"TOXICITY": {}, "INSULT": {}}, }).execute() print(response["attributeScores"]["TOXICITY"]["summaryScore"]["value"]) ``` comments:analyze ```bash curl "https://layahost.com/v1alpha1/comments:analyze?key=$LAYAHOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "comment": {"text": "You are an idiot and nobody wants you here."}, "requestedAttributes": {"TOXICITY": {}, "INSULT": {}}, "doNotStore": true }' ``` comments:analyze ```javascript // was: https://commentanalyzer.googleapis.com/v1alpha1/comments:analyze const res = await fetch( `https://layahost.com/v1alpha1/comments:analyze?key=${process.env.LAYAHOST_API_KEY}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ comment: { text: "You are an idiot and nobody wants you here." }, requestedAttributes: { TOXICITY: {}, INSULT: {} }, }), }, ); const data = await res.json(); console.log(data.attributeScores.TOXICITY.summaryScore.value); ``` Response · 200 ```json { "attributeScores": { "TOXICITY": { "spanScores": [{ "begin": 0, "end": 43, "score": { "value": 0.90833, "type": "PROBABILITY" } }], "summaryScore": { "value": 0.90833, "type": "PROBABILITY" } }, "INSULT": { "spanScores": [{ "begin": 0, "end": 43, "score": { "value": 0.91625, "type": "PROBABILITY" } }], "summaryScore": { "value": 0.91625, "type": "PROBABILITY" } } }, "languages": ["en"], "detectedLanguages": ["en"] } ``` ## Attributes | Attribute | Meaning | | --- | --- | | `TOXICITY` | Rude, disrespectful or unreasonable, likely to make people leave a discussion. | | `SEVERE_TOXICITY` | Very hateful, aggressive or abusive. Much less sensitive than TOXICITY to mild rudeness or casual swearing. The least accurate attribute; see Accuracy. | | `IDENTITY_ATTACK` | Negative or hateful towards people because of their identity, such as race, religion, gender or sexual orientation. | | `INSULT` | Insulting, inflammatory or demeaning towards a person or a group of people. | | `PROFANITY` | Swear words, curse words or other obscene language. | | `THREAT` | An intention to inflict pain, injury or violence on a person or group. | | `SEXUALLY_EXPLICIT` | References to sexual acts, sexual body parts or other lewd content. | The `_EXPERIMENTAL` names of these attributes, such as `TOXICITY_EXPERIMENTAL`, are answered by the same attribute. Perspective attributes we don't score (`FLIRTATION`, `ATTACK_ON_AUTHOR`, `SPAM`, the bridging attributes and the rest) are left out of `attributeScores` and not billed, so clients that request them by default keep working. A name Perspective never had gets `400`. For other decisions, such as spam or your own categories, use [POST /v1/decide](https://layahost.com/docs/decide). ## What is supported | Field | Behaviour | | --- | --- | | `comment.text`, `comment.type` | Up to 32,000 characters. `HTML` is reduced to its text before scoring. | | `requestedAttributes` | Any of the attributes above. `scoreThreshold` leaves out scores below it. `scoreType` must be `PROBABILITY` or unset. | | `languages` | Used as a hint. Without it, the language is detected and returned in `detectedLanguages`. | | `doNotStore` | Honoured. We never store comment text by default anyway; with `doNotStore` it is not stored even if you turned on text logging. | | `clientToken` | Echoed back. | | `spanScores` | One span covering the whole comment, with the same score as `summaryScore`. | | `context`, `communityId`, `sessionId`, `spanAnnotations` | Accepted and ignored. | | `comments:suggestscore` | Accepted so feedback code keeps working. Free, and nothing is stored. | | Batches | `POST /batch` speaks Google's batch protocol, so `new_batch_http_request()` works. Up to 100 requests per batch, each authenticated and billed on its own. | ## Accuracy Every score is `P(attribute)` from Laya, calibrated against the [Civil Comments](https://huggingface.co/datasets/google/civil_comments) dataset, where each label is the share of human raters who marked the comment, the same meaning as a Perspective probability. A score of 0.8 means that about 8 in 10 raters would call the comment toxic. Held-out results on 580 comments from the Civil Comments test split, never used for tuning (2026-09-25): | Attribute | ROC AUC | Rank correlation | Mean abs. error | | --- | --- | --- | --- | | `TOXICITY` | 0.92 | 0.77 | 0.09 | | `SEVERE_TOXICITY` | 0.57 | 0.34 | 0.03 | | `IDENTITY_ATTACK` | 0.93 | 0.50 | 0.04 | | `INSULT` | 0.95 | 0.79 | 0.07 | | `PROFANITY` | 0.94 | 0.54 | 0.03 | | `THREAT` | 0.98 | 0.52 | 0.03 | | `SEXUALLY_EXPLICIT` | 0.88 | 0.42 | 0.02 | ROC AUC is the chance that a comment raters flagged scores higher than one they did not (1.0 is perfect, 0.5 is a coin flip). Mean absolute error is the average distance between our score and the share of raters, on the natural mix of comments. **`SEVERE_TOXICITY` is barely better than chance** at telling very hateful comments from merely toxic ones. It is there so requests that ask for it keep working; don't base decisions on it. Use `TOXICITY` together with `INSULT`, `THREAT` and `IDENTITY_ATTACK` instead. `PROFANITY` combines the model with a list of common swear words: a match sets the score to at least 0.391, the average share of raters who called such comments obscene. Raters were conservative here, so a single swear word scores well below 0.5. ### Other languages Text that isn't English goes to Laya's multilingual checkpoint, with a separate calibration for `TOXICITY` and `INSULT`. On a held-out sample of the [TextDetox](https://huggingface.co/datasets/textdetox/multilingual_toxicity_dataset) dataset (300 comments per language, half of them toxic), `TOXICITY` gives: | Language | ROC AUC | Toxic caught at 0.5 | Clean flagged at 0.5 | Toxic caught at 0.7 | Clean flagged at 0.7 | | --- | --- | --- | --- | --- | --- | | English | 1.00 | 83% | 0% | 57% | 0% | | German | 0.85 | 37% | 5% | 23% | 3% | | Spanish | 0.91 | 63% | 5% | 45% | 3% | | French | 0.93 | 71% | 3% | 51% | 1% | | Italian | 0.82 | 63% | 13% | 44% | 7% | English is clearly the strongest. French, Spanish and Italian work with a lower threshold than you would use in English; German misses many toxic comments. Italian flags more clean comments than the others. The other attributes are calibrated for the multilingual checkpoint too, but only measured in English. For a site in another language, check a threshold on a few hundred of its own comments first. Perspective was trained on this kind of data and scores better on it. Expect different numbers for the same comment, and a different best threshold. Before switching, run a sample of your own comments through both and pick thresholds from ours. The model is English-first; see [other languages](#languages). ## Pricing Each requested attribute is one decision, $5 per million. A comment scored for `TOXICITY` alone costs $0.000005; all 7 attributes cost $0.000035. Failed requests and `suggestscore` are free. New accounts get $1 of credit when they verify their email. ## Errors Errors use Google's shape, so `googleapiclient` raises its usual `HttpError`: Response · 400 ```json { "error": { "code": 400, "message": "Unknown attribute: TOXCITY. Supported: TOXICITY, SEVERE_TOXICITY, IDENTITY_ATTACK, INSULT, PROFANITY, THREAT, SEXUALLY_EXPLICIT.", "status": "INVALID_ARGUMENT" } } ``` | Status | When | | --- | --- | | `400 INVALID_ARGUMENT` | Invalid body, unknown attribute, or an invalid API key. | | `401 UNAUTHENTICATED` | No API key. | | `402 FAILED_PRECONDITION` | Your balance is too low. Top up on the [Billing](https://layahost.com/billing) page. | | `429 RESOURCE_EXHAUSTED` | Rate limit (600 requests per minute per key) or a busy backend. Retry after `retry-after` seconds. | | `503 UNAVAILABLE` | Backend unavailable. Retry shortly. | --- # Perspective integrations Many tools call the Perspective API for you. Here is how to point each one at layahost. Where a tool has a setting for the endpoint, no code changes; where it has the Google URL built in, the change is one line. Every tool needs a layahost API key from the [API keys](https://layahost.com/keys) page in place of the Google key. Scores come from a different model: re-check the thresholds you set in the tool. See [Accuracy](https://layahost.com/docs/perspective#accuracy). ## No code changes ### Coral by Vox Media Coral's toxic comment filter has a custom endpoint setting. In the Coral admin, open **Configure → Moderation → Perspective toxic comment filter** and set: | Setting | Value | | --- | --- | | Custom endpoint | `https://layahost.com/v1alpha1` | | API key | Your layahost key (`lh_…`) | | Model | `TOXICITY` (or any [supported attribute](https://layahost.com/docs/perspective)) | Coral adds `/comments:analyze` to the endpoint and sends the key as `?key=`, which layahost accepts. The option to send moderation decisions back to Perspective keeps working through `comments:suggestscore`; layahost accepts those calls for free and stores nothing. Coral sends the site's language as a hint: layahost scores other languages too, but is most accurate in English. ### dingo dingo's `LLMPerspective` evaluator takes the discovery URL as `api_url`. In its Perspective example that is the `PERSPECTIVE_API_URL` environment variable: Environment ```bash export PERSPECTIVE_API_KEY="lh_your_key" export PERSPECTIVE_API_URL='https://layahost.com/$discovery/rest?version=v1alpha1' ``` ### augustus The `perspective.Perspective` detector has an `api_url` option. augustus only adds `?key=` to Google's own URL, so put the key in the URL yourself, and keep `api_key` set because it is required: Detector options ```yaml api_key: lh_your_key api_url: https://layahost.com/v1alpha1/comments:analyze?key=lh_your_key attrs: [TOXICITY] ``` ### Your own code with googleapiclient Change `discoveryServiceUrl` to `https://layahost.com/$discovery/rest?version=v1alpha1` and the key. Single requests and `new_batch_http_request()` batches both work. Full example in the [migration guide](https://layahost.com/docs/perspective). ## One-line changes These tools have the Google address built in. Until they add a setting, change the line below in your copy. | Tool | File | Change | | --- | --- | --- | | Stanford HELM | `src/helm/clients/perspective_api_client.py` | `discoveryServiceUrl` to `https://layahost.com/$discovery/rest?version=v1alpha1`. HELM's batches and its default `FLIRTATION` attribute work: `flirtation_score` stays empty. | | lm-evaluation-harness | `lm_eval/tasks/realtoxicityprompts/metric.py` | The `commentanalyzer.googleapis.com` URL to `https://layahost.com/v1alpha1/comments:analyze`. `PERSPECTIVE_API_KEY` takes your layahost key. | | DataFlow | `dataflow/serving/google_api_serving.py` | `discoveryServiceUrl` to `https://layahost.com/$discovery/rest?version=v1alpha1`; `GOOGLE_API_KEY` takes your layahost key. | | 4CAT | `processors/machine_learning/perspective.py` | The `discoveryServiceUrl` in `discovery.build()` to `https://layahost.com/$discovery/rest?version=v1alpha1`; the `api.google.api_key` setting takes your layahost key. | | Discourse Perspective plugin | `lib/discourse_perspective.rb` | `GOOGLE_API_DOMAIN` to `https://layahost.com`; the plugin's API key setting takes your layahost key. | Using a tool that is not listed? Anything that lets you change the host works: replace `commentanalyzer.googleapis.com` with `layahost.com`. If it doesn't, write to [support@layahost.com](mailto:support@layahost.com) and we will look at it. --- # POST /v1/decide One text in, one decision out. Use a ready-made [template](https://layahost.com/docs/templates) or ask your own question, and get a flat response with the decision, its probabilities and the cost. Endpoint ```http POST https://layahost.com/v1/decide ``` For several questions about the same text in one request, use the Jev-compatible [POST /v1/systemone](https://layahost.com/docs/systemone) instead. For many texts at once, see [Batch requests](https://layahost.com/docs/batch). ## Request body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `text` | string | Yes | The text to decide about. Up to 32,000 characters. | | `template` | string | No | A template slug: `moderation`, `spam`, `support_intent`, `routing`, `sentiment`, `priority` or `language`. See [Decision templates](https://layahost.com/docs/templates). | | `type` | string | Without `template` | `choice`, `yes_no` or `score`. Ignored when you use a template. | | `question` | string | Without `template` | The question to answer, up to 2,000 characters. With a template, replaces the template's question. | | `options` | array or object | For your own `choice` and `score` | The possible answers, either as a list of names (`["billing", "sales"]`) or as an object that maps each name to a short description (`{"billing": "invoices, payments"}`). `choice` takes 2 or more options, within the [option budget](#limits); `score` takes 2 to 10 levels, lowest first. Names can be numbers, such as `["1", "2", "3"]`. Descriptions can be up to 500 characters. With a template, replaces the template's options. Ignored for `yes_no`. | | `lang` | string | No | A language hint such as `en` or `de`. With `laya-auto`, `en` sends the text to the English checkpoint and any other value to the multilingual one, instead of detecting the language. | | `model` | string | No | `laya-auto` (default), `laya-english` or `laya-multilingual`. See [Models](https://layahost.com/docs/models). | | `cache` | boolean | No | Default `true`. Set `false` to skip the response cache: the answer is computed fresh and not stored. | ## Example A `choice` question with a short description for each option: POST /v1/decide ```bash curl https://layahost.com/v1/decide \ -H "Authorization: Bearer $LAYAHOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Can I get an invoice with my VAT number on it?", "type": "choice", "question": "Which team should answer this email?", "options": { "billing": "invoices, payments, refunds", "technical": "bugs, errors, API", "sales": "pricing, plans, quotes" } }' ``` POST /v1/decide ```javascript const res = await fetch("https://layahost.com/v1/decide", { method: "POST", headers: { Authorization: `Bearer ${process.env.LAYAHOST_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ text: "Can I get an invoice with my VAT number on it?", type: "choice", question: "Which team should answer this email?", options: { billing: "invoices, payments, refunds", technical: "bugs, errors, API", sales: "pricing, plans, quotes", }, }), }); if (!res.ok) throw new Error(`layahost ${res.status}: ${await res.text()}`); const result = await res.json(); console.log(result.decision, result.probabilities); ``` POST /v1/decide ```python import os import requests res = requests.post( "https://layahost.com/v1/decide", headers={"Authorization": f"Bearer {os.environ['LAYAHOST_API_KEY']}"}, json={ "text": "Can I get an invoice with my VAT number on it?", "type": "choice", "question": "Which team should answer this email?", "options": { "billing": "invoices, payments, refunds", "technical": "bugs, errors, API", "sales": "pricing, plans, quotes", }, }, timeout=10, ) res.raise_for_status() result = res.json() print(result["decision"], result["probabilities"]) ``` POST /v1/decide ```php $client = new GuzzleHttp\Client(['base_uri' => 'https://layahost.com']); $res = $client->post('/v1/decide', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('LAYAHOST_API_KEY')], 'json' => [ 'text' => 'Can I get an invoice with my VAT number on it?', 'type' => 'choice', 'question' => 'Which team should answer this email?', 'options' => [ 'billing' => 'invoices, payments, refunds', 'technical' => 'bugs, errors, API', 'sales' => 'pricing, plans, quotes', ], ], ]); $result = json_decode($res->getBody(), true); echo $result['decision'], PHP_EOL; ``` Response · 200 ```json { "decision": "billing", "confidence": 0.6506, "probabilities": { "billing": 0.9036, "technical": 0.0506, "sales": 0.0458 }, "template": null, "model": "laya-english", "usage": { "decisions": 1, "input_tokens": 52, "cost_micros": 5, "cost_usd": 5.0e-6 }, "latency_ms": 169.63, "request_id": "req_iazyijj7zo5nmmoxzhnivu9d" } ``` ## Response | Field | Type | Description | | --- | --- | --- | | `decision` | string or boolean | The answer: the chosen option for `choice`, `true` or `false` for `yes_no`, and the level name for `score`. | | `confidence` | number | How sure the model is, from 0 to 1. See [Reading confidence](#confidence). | | `probabilities` | object | `choice` and `score` only. The probability of each option, keyed by option name. Sorted most likely first for `choice`, in level order for `score`. | | `probability` | number | `yes_no` only. The probability that the answer is yes. `decision` is `true` when it is 0.5 or more. | | `score` | number | `score` only. The expected level on a scale where 0 is the first level and *n*−1 the last. | | `template` | string or null | The template you used, or `null`. | | `model` | string | The model that answered: `laya-english` or `laya-multilingual`. The `language` template reports `lingua`, its dedicated language detector. | | `usage.decisions` | integer | Decisions billed. Always `1` on this endpoint. | | `usage.input_tokens` | integer | Tokens the model read (text plus question and options). | | `usage.cost_micros` | integer | What the request cost, in millionths of a US dollar: `5` is $0.000005. Use this for exact accounting. | | `usage.cost_usd` | number | The same cost in US dollars. A JSON number, which may be written in exponent form: `5.0e-6` is $0.000005. | | `latency_ms` | number | Server-side processing time in milliseconds. | | `request_id` | string | Unique ID of the request, also sent in the `x-request-id` and `x-typesafe-request-id` headers and shown in [Logs](https://layahost.com/logs). | ## Use a template Templates bundle a tested question and options. Send only the text and the template slug. This one is a `score` template, so the response also has `score`: POST /v1/decide ```bash curl https://layahost.com/v1/decide \ -H "Authorization: Bearer $LAYAHOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Our whole checkout is down and we are losing orders right now!", "template": "priority" }' ``` Response · 200 ```json { "decision": "urgent", "score": 2.9086, "confidence": 0.8195, "probabilities": { "low": 0.0133, "normal": 0.0154, "high": 0.0207, "urgent": 0.9506 }, "template": "priority", "model": "laya-english", "usage": { "decisions": 1, "input_tokens": 46, "cost_micros": 5, "cost_usd": 5.0e-6 }, "latency_ms": 101.57, "request_id": "req_fhmwim3yoqgdfdxf4mzsfp7l" } ``` See [Decision templates](https://layahost.com/docs/templates) for the full list. ## Ask a yes/no question Set `type` to `yes_no`. No options are needed. The response has `probability` instead of `probabilities`: POST /v1/decide ```bash curl https://layahost.com/v1/decide \ -H "Authorization: Bearer $LAYAHOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "The delivery was two weeks late and nobody answered my emails.", "type": "yes_no", "question": "Is the customer complaining?" }' ``` Response · 200 ```json { "decision": true, "probability": 0.8481, "confidence": 0.8481, "template": null, "model": "laya-english", "usage": { "decisions": 1, "input_tokens": 43, "cost_micros": 5, "cost_usd": 5.0e-6 }, "latency_ms": 96.41, "request_id": "req_7oymodjner418isoiy49zsjs" } ``` When accuracy matters, a `choice` question with two described options is often more reliable than a bare yes/no question. Try both on your data. ## Score on a scale Set `type` to `score` and list the levels from lowest to highest: POST /v1/decide ```bash curl https://layahost.com/v1/decide \ -H "Authorization: Bearer $LAYAHOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "The delivery was two weeks late and nobody answered my emails.", "type": "score", "question": "How angry is the customer?", "options": ["calm", "annoyed", "angry", "furious"] }' ``` Response · 200 ```json { "decision": "angry", "score": 1.5788, "confidence": 0.2046, "probabilities": { "calm": 0.0319, "annoyed": 0.5244, "angry": 0.2768, "furious": 0.1669 }, "template": null, "model": "laya-english", "usage": { "decisions": 1, "input_tokens": 45, "cost_micros": 5, "cost_usd": 5.0e-6 }, "latency_ms": 97.47, "request_id": "req_7hwhzpjgkpibhof91s7x7wmc" } ``` `score` is the expected level: the sum of each level's position (0 for `calm` up to 3 for `furious`) times its probability. `decision` is the level nearest to it. That is not always the single most likely level: here `annoyed` has the highest probability, but the expected score of 1.58 is closest to level 2, `angry`. If you need the most likely level, take the largest value in `probabilities`. Levels can be named with numbers, for a star rating for example. `score` still counts positions from 0, so a score of 1.31 is nearest the second level, `"2"`: POST /v1/decide ```bash curl https://layahost.com/v1/decide \ -H "Authorization: Bearer $LAYAHOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "The product is fine but shipping took forever.", "type": "score", "question": "Rate the review from 1 to 5 stars.", "options": ["1", "2", "3", "4", "5"] }' ``` Response · 200 ```json { "decision": "2", "score": 1.3059, "confidence": 0.3685, "probabilities": { "1": 0.0701, "2": 0.679, "3": 0.1651, "4": 0.0466, "5": 0.0392 }, "template": null, "model": "laya-english", "usage": { "decisions": 1, "input_tokens": 50, "cost_micros": 5, "cost_usd": 5.0e-6 }, "latency_ms": 120.5, "request_id": "req_uzivv1hv24gdcyf2bidnyilj" } ``` Send numeric names as strings. JSON numbers such as `[1, 2, 3]` are not accepted for `score` levels. ## Override a template With a template, `question` and `options` replace the template's own. This is how you route to your own teams with the `routing` template: POST /v1/decide ```bash curl https://layahost.com/v1/decide \ -H "Authorization: Bearer $LAYAHOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "My order arrived broken, I want my money back.", "template": "routing", "options": { "logistics": "shipping, delivery, damaged parcels", "finance": "refunds, invoices, payments", "product": "product questions and feedback" } }' ``` Response · 200 ```json { "decision": "finance", "confidence": 0.108, "probabilities": { "finance": 0.568, "logistics": 0.243, "product": 0.189 }, "template": "routing", "model": "laya-english", "usage": { "decisions": 1, "input_tokens": 52, "cost_micros": 5, "cost_usd": 5.0e-6 }, "latency_ms": 96.03, "request_id": "req_rzgml9eicffev62s1ogyfgpm" } ``` The message fits two teams, and the low `confidence` says so. That is a good case to send to a person. ## Reading confidence `confidence` runs from 0 to 1, but it is computed differently per question type: - **`choice` and `score`**: 1 minus the normalised entropy of `probabilities`. It is 0 when every option is equally likely and 1 when one option has all of the probability. It is usually well below the top probability: 0.108 for a 0.568 favourite in the example above. - **`yes_no`**: the larger of `probability` and 1 − `probability`, so it ranges from 0.5 to 1. - **`language` template**: the probability of the detected language. Choose a threshold per question on your own data. A common pattern is to act automatically above the threshold and send everything else to a person. ## Limits | Field | Limit | | --- | --- | | `text` | 32,000 characters | | `question` | 2,000 characters | | `options` for `choice` | At least 2. All option names and descriptions of one question must fit in the model's option budget of 192 tokens, roughly 100 to 150 short options. Longer lists get `422`. | | `options` for `score` | 2 to 10 levels | | Each option description | 500 characters | Identical requests may be answered from a short-lived cache unless you send `"cache": false`. The answer is the same and is billed as usual. See [Data & privacy](https://layahost.com/docs/data). ## Errors An invalid body gets `422` with one entry per problem. It is not billed. Here the request had neither a `template` nor a `type` and `question`: Response · 422 ```json { "detail": [ { "loc": ["body", "type"], "msg": "The type field is required when template is not present.", "type": "value_error" }, { "loc": ["body", "question"], "msg": "The question field is required when template is not present.", "type": "value_error" } ] } ``` A `choice` or `score` question without `options`: Response · 422 ```json { "detail": [ { "loc": ["body", "options"], "msg": "The options field is required.", "type": "value_error" } ] } ``` A `score` question with fewer than 2 or more than 10 levels: Response · 422 ```json { "detail": [ { "loc": ["body", "options"], "msg": "A score needs 2 to 10 levels.", "type": "value_error" } ] } ``` Options that don't fit the option budget get `422` on `["body", "options"]` with a message that says so. See [Errors](https://layahost.com/docs/errors) for authentication, balance, rate-limit and capacity errors. --- # Decision templates Templates are ready-made questions for common tasks. Send a `template` and your `text` to [POST /v1/decide](https://layahost.com/docs/decide) and you get a decision without writing a question or options. ## Available templates | Template | Type | Options | What it decides | | --- | --- | --- | --- | | `moderation` | choice | `safe`, `toxic` | Flags insults, harassment, hate speech and threats. | | `spam` | choice | `spam`, `legit` | Separates spam, scams and phishing from genuine messages. | | `support_intent` | choice | `question`, `bug_report`, `refund`, `cancellation`, `feature_request`, `praise`, `other` | What the customer wants from a support ticket. | | `routing` | choice | `billing`, `technical`, `sales`, `other` | Which team should handle a message. Pass your own teams in `options`. | | `sentiment` | choice | `positive`, `neutral`, `negative` | The tone of a message. | | `priority` | score | `low`, `normal`, `high`, `urgent` | How urgent a message is. | | `language` | choice | ISO 639-1 codes | Which language the text is written in. See [Language detection](#language). | Each template is a single decision and is billed like any other: $0.000005 per request. ## How the options are defined The model sees a short description of each option, not just its name. These descriptions decide where the borderline cases go: | Template | Option | Description given to the model | | --- | --- | --- | | `moderation` | `safe` | polite, neutral or harshly critical content about a product, service or idea, without attacking a person | | | `toxic` | insults or slurs aimed at a person, harassment, hate speech, threats of violence or revenge, telling someone to hurt themselves | | `spam` | `spam` | unsolicited advertising, scam, phishing, fake prizes, suspicious links | | | `legit` | genuine message: question, complaint, refund request, bug report or thanks | | `support_intent` | `question` | asks for information or instructions | | | `bug_report` | reports an error or something not working | | | `refund` | wants money back or reports a wrong charge | | | `cancellation` | wants to cancel or close the account | | | `feature_request` | asks for a new feature or improvement | | | `praise` | says thanks or compliments | | | `other` | anything else | | `routing` | `billing` | existing charges, invoices, payments, refunds, billing details | | | `technical` | bugs, errors, outages, crashes, API and integrations | | | `sales` | prices of plans, quotes, buying, upgrading, new contracts | | | `other` | jobs, office locations, partnerships and anything else | `sentiment` and `priority` use the option names alone. If a template's definitions don't match your policy, override them as shown below. ## Example POST /v1/decide ```bash curl https://layahost.com/v1/decide \ -H "Authorization: Bearer $LAYAHOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Congratulations! You won a $500 gift card. Claim it now at bit.ly/prize-claim", "template": "spam" }' ``` Response · 200 ```json { "decision": "spam", "confidence": 0.4058, "probabilities": { "spam": 0.8562, "legit": 0.1438 }, "template": "spam", "model": "laya-english", "usage": { "decisions": 1, "input_tokens": 83, "cost_micros": 5, "cost_usd": 5.0e-6 }, "latency_ms": 130.72, "request_id": "req_y4k2bsiy6d7gatbik1y82rnk" } ``` ## Override a template Any template accepts the same `question` and `options` fields as a custom question. They replace the template's own, so you can keep the template's slug in your logs while using your own wording or categories: - `options` as a list of names or as an object of names and descriptions. For `routing`, pass your own teams. - `question` to reword the question. There is a full example in [POST /v1/decide](https://layahost.com/docs/decide). Wording changes can move results a lot, so test your version on real messages. ## Language detection The `language` template does not use Laya. It runs a dedicated language detector and returns ISO 639-1 codes. Without `options`, it considers every supported language and returns the five most likely: POST /v1/decide ```bash curl https://layahost.com/v1/decide \ -H "Authorization: Bearer $LAYAHOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Dzień dobry, mam pytanie o fakturę.", "template": "language" }' ``` Response · 200 ```json { "decision": "pl", "confidence": 0.919, "probabilities": { "pl": 0.919, "sk": 0.0184, "sv": 0.0138, "da": 0.0084, "nb": 0.005 }, "template": "language", "model": "lingua", "usage": { "decisions": 1, "input_tokens": 0, "cost_micros": 5, "cost_usd": 5.0e-6 }, "latency_ms": 44.7, "request_id": "req_aelx2cghpfzvdnoz7nriwqzn" } ``` Pass `options` with the codes you expect to choose among them only. The probabilities are then normalised over your list: POST /v1/decide ```bash curl https://layahost.com/v1/decide \ -H "Authorization: Bearer $LAYAHOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Dzień dobry, mam pytanie o fakturę.", "template": "language", "options": ["en", "pl", "de"] }' ``` Response · 200 ```json { "decision": "pl", "confidence": 0.9964, "probabilities": { "pl": 0.9964, "de": 0.0029, "en": 0.0007 }, "template": "language", "model": "lingua", "usage": { "decisions": 1, "input_tokens": 0, "cost_micros": 5, "cost_usd": 5.0e-6 }, "latency_ms": 0.84, "request_id": "req_igquswea95s7kdti2jwezefc" } ``` Supported codes: `en`, `pl`, `de`, `fr`, `es`, `it`, `pt`, `nl`, `uk`, `cs`, `sk`, `ru`, `sv`, `da`, `nb`, `fi`, `hu`, `ro`, `tr`, `ja`, `zh`, `ko`, `ar`, `hi`, `el`, `bg`, `hr`, `lt`, `lv`, `et`, `sl`, `sr`, `he`, `vi` and `id`. `GET /v1/templates` lists the same codes. - `confidence` is the probability of the detected language. - `model` is `lingua` and `usage.input_tokens` is `0`. The `model` and `lang` request fields are ignored. - An unsupported code in `options` returns `422` on `["body", "options"]`, with the message “Unsupported language codes: xx. See GET /v1/templates.” - Very short texts (a word or two) are hard to identify in any language. Check `confidence`. ## List templates `GET /v1/templates` returns every template with its type, option names and an example text. Use it to build a picker or to check what a slug does. GET /v1/templates ```bash curl https://layahost.com/v1/templates \ -H "Authorization: Bearer $LAYAHOST_API_KEY" ``` Response · 200 ```json { "templates": [ { "template": "moderation", "name": "Moderation", "description": "Flags insults, harassment, hate speech and threats.", "type": "choice", "options": ["safe", "toxic"], "example": "You are all idiots and I hope you get fired." }, { "template": "spam", "name": "Spam", "description": "Separates spam and scams from genuine messages.", "type": "choice", "options": ["spam", "legit"], "example": "Earn $5000 a day from home! Message me on WhatsApp." }, { "template": "support_intent", "name": "Support intent", "description": "What the customer wants from a support ticket.", "type": "choice", "options": ["question", "bug_report", "refund", "cancellation", "feature_request", "praise", "other"], "example": "I was charged twice this month, please refund one payment." }, { "template": "routing", "name": "Routing", "description": "Sends a message to the right team. Pass your own departments in \"options\".", "type": "choice", "options": ["billing", "technical", "sales", "other"], "example": "The API returns 502 errors since this morning." }, { "template": "sentiment", "name": "Sentiment", "description": "Positive, neutral or negative tone.", "type": "choice", "options": ["positive", "neutral", "negative"], "example": "Thanks for the quick help, everything works great!" }, { "template": "priority", "name": "Priority", "description": "How urgent a message is, on a 4-level scale.", "type": "score", "options": ["low", "normal", "high", "urgent"], "example": "Our whole checkout is down and we are losing orders right now!" }, { "template": "language", "name": "Language", "description": "Which language the text is written in (ISO 639-1, 35 languages). Pass \"options\" to narrow the list.", "type": "choice", "options": ["en", "pl", "de", "fr", "es", "it", "pt", "nl", "uk", "cs", "sk", "ru", "sv", "da", "nb", "fi", "hu", "ro", "tr", "ja", "zh", "ko", "ar", "hi", "el", "bg", "hr", "lt", "lv", "et", "sl", "sr", "he", "vi", "id"], "example": "Dzień dobry, mam pytanie o fakturę." } ] } ``` Listing templates is free and counts towards your [rate limit](https://layahost.com/docs/rate-limits) like any other request. --- # POST /v1/systemone The Jev-compatible endpoint. Ask up to 32 typed questions about the same state in one request. The request, response and error formats match TypeSafe AI's Jev System One API, so the official Jev SDKs work unchanged. To send many requests in one call, use [POST /v1/systemone/batch](https://layahost.com/docs/batch). Endpoint ```http POST https://layahost.com/v1/systemone ``` Coming from Jev? Start with [Migrate from Jev](https://layahost.com/docs/migrate-from-jev). For a single question with a flat response, [POST /v1/decide](https://layahost.com/docs/decide) is simpler. ## Request body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `state` | string, object or array | Yes | The text or structured data to evaluate. Objects and arrays are sent to the model as JSON. Up to 32,000 characters. | | `model` | string | Yes | `laya-auto`, `laya-english` or `laya-multilingual`. Any `jev-*` name, such as `jev-latest`, is answered by `laya-auto`. The SDKs send `jev-latest` unless you set a model. See [Models](https://layahost.com/docs/models). | | `questions` | object | Yes | 1 to 32 [questions](#questions), keyed by names you choose. Each answer comes back under the same name. | | `lang` | string | No | layahost extension. A language hint such as `en` or `de`. With `laya-auto`, `en` sends the state to the English checkpoint and any other value to the multilingual one, instead of detecting the language. Ignored by other models. | | `cache` | boolean | No | layahost extension. Default `true`. Set `false` to skip the [response cache](#cache). | ## Questions Every question has a `type`, the question itself in `instructions`, and for most types a set of possible answers in `criteria`. Instructions and criteria can be plain strings or JSON objects and arrays. `instructions` is optional, as in Jev, but a short question tells the model what you are asking, so write one. | `type` | `criteria` | Answer | | --- | --- | --- | | `noul` | Optional. `{"true": "...", "false": "..."}` describing what counts as yes and as no. | Yes/no. `noul` is the probability of yes. | | `choice` | Required. An object that maps each label to a description, or to `null`. At least 2 labels, within the [option budget](#limits). | One of the labels, with a probability for each. | | `score` | Required. An array of levels, lowest first. 2 to 10 levels. | The expected level, with a probability for each level. | ## Example Three questions about one customer message: a `choice`, a `noul` and a `score`. POST /v1/systemone ```bash curl https://layahost.com/v1/systemone \ -H "Authorization: Bearer $LAYAHOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "state": "Hi, I ordered a blue jacket two weeks ago and it still has not arrived. This is the second time this happens. If it is not here by Friday I want a full refund.", "model": "laya-auto", "questions": { "intent": { "type": "choice", "instructions": "What does the customer want?", "criteria": { "refund": "wants money back", "delivery_status": "asks where the order is", "product_question": "asks about a product", "other": null } }, "is_complaint": { "type": "noul", "instructions": "Is the customer complaining?" }, "anger": { "type": "score", "instructions": "How angry is the customer?", "criteria": ["calm", "annoyed", "angry", "furious"] } } }' ``` POST /v1/systemone ```javascript // npm install @typesafe-ai/sdk import { choice, noul, score, TypeSafeClient } from "@typesafe-ai/sdk"; const client = new TypeSafeClient({ baseURL: "https://layahost.com", apiKey: process.env.LAYAHOST_API_KEY, }); const result = await client.systemOne({ model: "laya-auto", state: "Hi, I ordered a blue jacket two weeks ago and it still has not arrived. This is the second time this happens. If it is not here by Friday I want a full refund.", questions: { intent: choice("What does the customer want?", { refund: "wants money back", delivery_status: "asks where the order is", product_question: "asks about a product", other: null, }), is_complaint: noul("Is the customer complaining?"), anger: score("How angry is the customer?", ["calm", "annoyed", "angry", "furious"]), }, }); console.log(result.answers.intent.choice); // "refund" console.log(result.answers.is_complaint.noul); // probability of yes console.log(result.answers.anger.score); // 0 = calm ... 3 = furious ``` POST /v1/systemone ```python # pip install typesafe-sdk import os from typesafe_sdk import Choice, Noul, Score, TypeSafeClient with TypeSafeClient( base_url="https://layahost.com", api_key=os.environ["LAYAHOST_API_KEY"], ) as client: result = client.system_one( model="laya-auto", state=( "Hi, I ordered a blue jacket two weeks ago and it still has not arrived. " "This is the second time this happens. If it is not here by Friday I want a full refund." ), questions={ "intent": Choice( instructions="What does the customer want?", criteria={ "refund": "wants money back", "delivery_status": "asks where the order is", "product_question": "asks about a product", "other": None, }, ), "is_complaint": Noul(instructions="Is the customer complaining?"), "anger": Score( instructions="How angry is the customer?", criteria=["calm", "annoyed", "angry", "furious"], ), }, ) print(result.choices["intent"].choice) # "refund" print(result.nouls["is_complaint"].noul) # probability of yes print(result.scores["anger"].score) # 0 = calm ... 3 = furious ``` POST /v1/systemone ```php $client = new GuzzleHttp\Client(['base_uri' => 'https://layahost.com']); $res = $client->post('/v1/systemone', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('LAYAHOST_API_KEY')], 'json' => [ 'model' => 'laya-auto', 'state' => 'Hi, I ordered a blue jacket two weeks ago and it still has not arrived. ' . 'This is the second time this happens. If it is not here by Friday I want a full refund.', 'questions' => [ 'intent' => [ 'type' => 'choice', 'instructions' => 'What does the customer want?', 'criteria' => [ 'refund' => 'wants money back', 'delivery_status' => 'asks where the order is', 'product_question' => 'asks about a product', 'other' => null, ], ], 'is_complaint' => ['type' => 'noul', 'instructions' => 'Is the customer complaining?'], 'anger' => [ 'type' => 'score', 'instructions' => 'How angry is the customer?', 'criteria' => ['calm', 'annoyed', 'angry', 'furious'], ], ], ], ]); $result = json_decode($res->getBody(), true); echo $result['answers']['intent']['choice'], PHP_EOL; echo $result['answers']['is_complaint']['noul'], PHP_EOL; echo $result['answers']['anger']['score'], PHP_EOL; ``` Response · 200 ```json { "model": "laya-english", "answers": { "intent": { "type": "choice", "choice": "refund", "probabilities": { "refund": 0.8808, "delivery_status": 0.0788, "product_question": 0.0268, "other": 0.0136 }, "confidence": 0.6627 }, "is_complaint": { "type": "noul", "noul": 0.7899, "confidence": 0.7899 }, "anger": { "type": "score", "score": 1.5154, "legend": { "0": "calm", "1": "annoyed", "2": "angry", "3": "furious" }, "probabilities": { "0": 0.0229, "1": 0.5684, "2": 0.279, "3": 0.1297 }, "confidence": 0.2579 } }, "usage": { "input_tokens": 218, "output_tokens": 0 }, "meta": { "checkpoint": "english", "cached": false, "latency_ms": 307.75, "request_id": "req_wxxjgdeytykryrijxucxwmq3", "decisions": 3, "cost_micros": 15, "cost_usd": 1.5e-5 } } ``` ## Response | Field | Type | Description | | --- | --- | --- | | `model` | string | The model that answered: `laya-english` or `laya-multilingual`. With `laya-auto` or a `jev-*` name, this is the checkpoint it chose. | | `answers` | object | One answer per question, under the question's name. Fields depend on the type (below). | | `usage.input_tokens` | integer | Tokens the model read, over all questions. | | `usage.output_tokens` | integer | Always `0`. Laya generates no text. | | `meta` | object | layahost extension, ignored by the Jev SDKs. See [meta](#meta). | ### Answer fields | Type | Field | Description | | --- | --- | --- | | `choice` | `choice` | The most likely label. | | | `probabilities` | Probability per label, in the order you sent them. | | | `confidence` | 1 minus the normalised entropy of `probabilities`: 0 when all labels are equally likely, 1 when one label has all of the probability. | | `noul` | `noul` | Probability that the answer is yes, from 0 to 1. | | | `confidence` | layahost extension: the larger of `noul` and 1 − `noul`. | | `score` | `score` | The expected level: each level's index (0 for the first) times its probability, summed. Ranges from 0 to *n*−1 and can fall between levels. | | | `legend` | Your levels keyed by index: `"0"` is the first. | | | `probabilities` | Probability per level, keyed by index. | | | `confidence` | Same measure as for `choice`. | ### meta | Field | Type | Description | | --- | --- | --- | | `checkpoint` | string | `english` or `multilingual`. | | `cached` | boolean | Whether the answers came from the response cache. | | `latency_ms` | number | Server-side processing time in milliseconds. | | `request_id` | string | Unique ID of the request, also in the `x-request-id` and `x-typesafe-request-id` headers and in [Logs](https://layahost.com/logs). | | `decisions` | integer | Decisions billed: one per question. | | `cost_micros` | integer | What the request cost, in millionths of a US dollar: `15` is $0.000015. Use this for exact accounting. | | `cost_usd` | number | The same cost in US dollars, as a JSON number that may be written in exponent form: `1.5e-5` is $0.000015. | ## Structured state `state` can be a JSON object or array, for example a ticket with its metadata. The model sees it as JSON text: POST /v1/systemone ```bash curl https://layahost.com/v1/systemone \ -H "Authorization: Bearer $LAYAHOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "state": { "subject": "Refund", "body": "Please refund my last order.", "customer_tier": "gold" }, "model": "laya-english", "cache": false, "questions": { "refund": { "type": "noul", "instructions": "Does the customer ask for a refund?" } } }' ``` Response · 200 ```json { "model": "laya-english", "answers": { "refund": { "type": "noul", "noul": 0.7831, "confidence": 0.7831 } }, "usage": { "input_tokens": 59, "output_tokens": 0 }, "meta": { "checkpoint": "english", "cached": false, "latency_ms": 96.18, "request_id": "req_zpkrvcyzsmdkqqpsuxy1dte6", "decisions": 1, "cost_micros": 5, "cost_usd": 5.0e-6 } } ``` ## Language hint With `laya-auto`, each request goes to the English checkpoint only when the state is detected as English, and to the multilingual checkpoint otherwise. If you already know the language, pass `lang` to skip detection: POST /v1/systemone ```bash curl https://layahost.com/v1/systemone \ -H "Authorization: Bearer $LAYAHOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "state": "Ich möchte mein Abo kündigen.", "model": "laya-auto", "lang": "de", "cache": false, "questions": { "cancel": { "type": "noul", "instructions": "Does the customer want to cancel?" } } }' ``` Response · 200 ```json { "model": "laya-multilingual", "answers": { "cancel": { "type": "noul", "noul": 0.9885, "confidence": 0.9885 } }, "usage": { "input_tokens": 40, "output_tokens": 0 }, "meta": { "checkpoint": "multilingual", "cached": false, "latency_ms": 31.08, "request_id": "req_5saxpesxm6qwhrssxrha8tjv", "decisions": 1, "cost_micros": 5, "cost_usd": 5.0e-6 } } ``` ## Caching Identical requests (same checkpoint, state and questions) may be answered from an in-memory cache for up to an hour. A cached response has the same answers, `meta.cached` set to `true` and a very low `latency_ms`. Repeating the three-question example above returns: meta · cached response ```json { "checkpoint": "english", "cached": true, "latency_ms": 0.37, "request_id": "req_cvwvpdksqcrakbv1cuzmr0mg", "decisions": 3, "cost_micros": 15, "cost_usd": 1.5e-5 } ``` Cached answers are billed like any other. Laya gives the same answer for the same input either way, so the cache only saves time. Set `"cache": false` to skip the cache completely: the answer is neither read from it nor stored in it. That is useful when you measure latency. ## Billing Each question is one decision, at $0.000005. The example above has three questions, so it costs $0.000015, as `meta.decisions` and `meta.cost_micros` show. Requests that fail are not billed. See [Pricing & billing](https://layahost.com/docs/pricing). ## Limits | What | Limit | | --- | --- | | Questions per request | 1 to 32 | | `state` | 32,000 characters (objects and arrays count as JSON text) | | `choice` labels | At least 2. Jev allows up to 255, but on layahost all labels and descriptions of one question must fit in the model's option budget of 192 tokens, roughly 100 to 150 short labels. Longer lists get `422`. | | `score` levels | 2 to 10 | ## Errors Invalid requests get `422` in the FastAPI format that Jev uses, and are not billed. An unknown model: Response · 422 ```json { "detail": [ { "loc": ["body", "model"], "msg": "Model 'gpt-4o' is not available. Use GET /v1/models.", "type": "value_error" } ] } ``` Errors in a question's shape point to the question by name, and include the offending `input` and a `ctx` object. Here the `choice` question `q` had a single label: Response · 422 ```json { "detail": [ { "type": "too_short", "loc": ["body", "questions", "q", "choice", "criteria"], "msg": "Dictionary should have at least 2 items after validation, not 1", "input": {"yes": null}, "ctx": { "field_type": "Dictionary", "min_length": 2, "actual_length": 1 } } ] } ``` A question whose labels and descriptions don't fit the option budget: Response · 422 ```json { "detail": [ { "loc": ["body", "questions", "category", "criteria"], "msg": "The options of this question are too long for the model: all option names and descriptions together must fit in 192 tokens (roughly 100-150 short options). Shorten descriptions or split the question.", "type": "value_error" } ] } ``` Too many questions: Response · 422 ```json { "detail": [ { "loc": ["body", "questions"], "msg": "At most 32 questions per request.", "type": "value_error" } ] } ``` See [Errors](https://layahost.com/docs/errors) for authentication, balance, rate-limit and capacity errors. --- # Batch requests Send many texts in one HTTP call. Each item is answered, billed and logged exactly like a single request, so a batch costs the same as sending the items one by one. It saves round trips, not money. Endpoints ```http POST https://layahost.com/v1/decide/batch POST https://layahost.com/v1/systemone/batch ``` ## The same decision for many texts `/v1/decide/batch` takes the body of [POST /v1/decide](https://layahost.com/docs/decide), with `texts` (a list of up to 64 strings) instead of `text`. The template or question applies to every text. POST /v1/decide/batch ```bash curl https://layahost.com/v1/decide/batch \ -H "Authorization: Bearer $LAYAHOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "template": "spam", "texts": [ "Congratulations! You won a free iPhone, claim it at bit.ly/xyz", "Hi, I was charged twice for my subscription this month." ] }' ``` POST /v1/decide/batch ```python import os import requests res = requests.post( "https://layahost.com/v1/decide/batch", headers={"Authorization": f"Bearer {os.environ['LAYAHOST_API_KEY']}"}, json={"template": "spam", "texts": messages}, timeout=30, ) res.raise_for_status() for item in res.json()["results"]: if item["status"] == 200: print(item["index"], item["decision"], item["confidence"]) else: print(item["index"], "failed:", item["detail"]) ``` POST /v1/decide/batch ```javascript const res = await fetch("https://layahost.com/v1/decide/batch", { method: "POST", headers: { Authorization: `Bearer ${process.env.LAYAHOST_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ template: "spam", texts: messages }), }); const { results, usage } = await res.json(); for (const item of results) { console.log(item.index, item.status === 200 ? item.decision : item.detail); } ``` Response · 200 ```json { "results": [ { "index": 0, "status": 200, "decision": "spam", "confidence": 0.3328, "probabilities": { "spam": 0.8258, "legit": 0.1742 }, "template": "spam", "model": "laya-english", "usage": { "decisions": 1, "input_tokens": 77, "cost_micros": 5, "cost_usd": 5.0e-6 }, "latency_ms": 325.72, "request_id": "req_yyc8n70ajnyo5rwgkhcwmceb_0" }, { "index": 1, "status": 200, "decision": "legit", "confidence": 0.0806, "probabilities": { "legit": 0.6656, "spam": 0.3344 }, "template": "spam", "model": "laya-english", "usage": { "decisions": 1, "input_tokens": 72, "cost_micros": 5, "cost_usd": 5.0e-6 }, "latency_ms": 231.02, "request_id": "req_yyc8n70ajnyo5rwgkhcwmceb_1" } ], "usage": { "decisions": 2, "failed": 0, "cost_micros": 10, "cost_usd": 1.0e-5 }, "request_id": "req_yyc8n70ajnyo5rwgkhcwmceb" } ``` Each item in `results` is the [/v1/decide response](https://layahost.com/docs/decide) plus its `index` and `status`. Results are in the order of `texts`. ## Many Jev requests at once `/v1/systemone/batch` takes `requests`, a list of up to 64 ordinary [/v1/systemone](https://layahost.com/docs/systemone) bodies, each with its own text, model and questions. At most 256 questions in total per batch. POST /v1/systemone/batch ```bash curl https://layahost.com/v1/systemone/batch \ -H "Authorization: Bearer $LAYAHOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "requests": [ {"state": "Our checkout is down!", "model": "jev-latest", "questions": {"urgent": {"type": "noul", "instructions": "Is this urgent?"}}}, {"state": "Love the new dashboard.", "model": "jev-latest", "questions": {"urgent": {"type": "noul", "instructions": "Is this urgent?"}}} ] }' ``` Response · 200 ```json { "responses": [ { "index": 0, "status": 200, "body": { "model": "laya-english", "answers": { "urgent": { "type": "noul", "noul": 0.733, "confidence": 0.733 } }, "usage": { "input_tokens": 35, "output_tokens": 0 }, "meta": { "request_id": "req_halhpd064uorzjutdn2mwwpo_0", "decisions": 1, "cost_micros": 5, "cost_usd": 5.0e-6, "latency_ms": 85.52 } } }, { "index": 1, "status": 200, "body": { "model": "laya-english", "answers": { "urgent": { "type": "noul", "noul": 0.0392, "confidence": 0.9608 } }, "usage": { "input_tokens": 35, "output_tokens": 0 }, "meta": { "request_id": "req_halhpd064uorzjutdn2mwwpo_1", "decisions": 1, "cost_micros": 5, "cost_usd": 5.0e-6, "latency_ms": 73.45 } } } ], "request_id": "req_halhpd064uorzjutdn2mwwpo" } ``` Each `body` is exactly what `/v1/systemone` would have returned for that request, success or error. ## Partial failures and billing - A batch answers `200` once its envelope is valid, even if some items fail. Check each item's `status`. - A bad item gets its own `422` and does not stop the rest. It is not billed. - Items run in order and are charged one by one. If your credit runs out halfway, the remaining items get `402` and are not billed. - An invalid envelope (no list, too many items, an unknown template) gets `422` for the whole batch and nothing runs. - Every item appears in [Logs](https://layahost.com/logs) with the batch request ID plus `_0`, `_1` and so on. - A batch counts as one request against your [rate limit](https://layahost.com/docs/rate-limits). Items are answered one after another, so a full batch takes about as long as its items added up: typically 2 to 5 seconds for 64 short texts. Set your HTTP timeout to at least 30 seconds. --- # GET /v1/usage Your balance and usage, for scripts and monitoring. Calling it is free. Every other response also carries what it cost and what is left, in two headers. Endpoint ```http GET https://layahost.com/v1/usage?days=30 ``` `days` is optional: 1 to 90, default 30. Usage covers the whole account, across all its keys. GET /v1/usage ```bash curl "https://layahost.com/v1/usage?days=7" \ -H "Authorization: Bearer $LAYAHOST_API_KEY" ``` Response · 200 ```json { "balance": { "micros": 17396635, "usd": 17.396635, "decisions_left": 3479327 }, "price_per_decision_micros": 5, "period": { "from": "2026-09-19", "to": "2026-09-25", "days": 7 }, "totals": { "requests": 1840, "decisions": 2905, "errors": 12, "cost_micros": 14525, "cost_usd": 0.014525 }, "daily": [ { "date": "2026-09-19", "requests": 0, "decisions": 0, "cost_micros": 0 }, { "date": "2026-09-20", "requests": 310, "decisions": 520, "cost_micros": 2600 } ] } ``` | Field | Description | | --- | --- | | `balance.micros` | Prepaid credit left, in millionths of a US dollar. | | `balance.decisions_left` | How many decisions that buys at the current price. | | `totals` | Requests, billed decisions, failed requests and cost over the period. | | `daily` | One row per day, oldest first, including days with no usage. Dates are UTC. | ## Cost and balance headers Every authenticated response, including batches and errors, has: | Header | Description | | --- | --- | | `x-layahost-cost-micros` | What this request was billed, in micro-USD. `0` for free and failed requests. | | `x-layahost-balance-micros` | Your balance after this request. | To be warned before credit runs out, set a low-balance alert on the [Billing](https://layahost.com/billing) page; you get one email when the balance falls under it. --- # Models layahost serves two checkpoints of Laya, an English one and a multilingual one, plus `laya-auto`, which picks between them for each request. Laya is an open-source typed-decision model by Convai Innovations, released under the Apache-2.0 licence. layahost hosts it as an unofficial service and is not affiliated with Convai Innovations or TypeSafe AI. ## Available models | Model | Description | | --- | --- | | `laya-auto` | The default for `/v1/decide`. Sends each request to `laya-english` or `laya-multilingual` based on the language of the text. | | `laya-english` | The English checkpoint, built on ModernBERT-large. For English text. | | `laya-multilingual` | The multilingual checkpoint, built on mmBERT-base. For text in other languages or mixed languages, and the faster of the two. | | `jev-latest` | An alias of `laya-auto`, so Jev clients and the SDKs' default model work unchanged. Any other `jev-*` name is accepted the same way. | All models cost the same: $0.000005 per decision. ## How laya-auto routes `laya-auto` detects the language of the text. Only text detected as English goes to `laya-english`. Everything else, including text whose language is unclear, goes to `laya-multilingual`, which copes better with mixed and non-English input. The response tells you which checkpoint answered: `model` is `laya-english` or `laya-multilingual`, never `laya-auto`. On `/v1/systemone`, `meta.checkpoint` says the same. If you already know the language, pass `lang` (for example `"lang": "de"`) to skip detection. `en` selects the English checkpoint, any other code the multilingual one. `lang` has no effect when you name a checkpoint directly. ## Choosing a model - Start with `laya-auto`. It suits most traffic, including a mix of languages. - Pin `laya-english` if all your text is English and you want every request answered by the same checkpoint. - Pin `laya-multilingual` for non-English text, or when speed matters more than the last bit of accuracy on English. The two checkpoints are different models and return different probabilities for the same input. If you compare probabilities with thresholds, tune them per checkpoint, or pin one model. The `language` template does not use Laya: it runs a dedicated language detector and reports `model` as `lingua`. See [Language detection](https://layahost.com/docs/templates#language). ## List models `GET /v1/models` returns the models you can use, in the Jev format. The SDKs expose it as `client.models.list()`. GET /v1/models ```bash curl https://layahost.com/v1/models \ -H "Authorization: Bearer $LAYAHOST_API_KEY" ``` Response · 200 ```json { "models": [ { "name": "laya-auto", "description": "Routes each request to the best Laya checkpoint for its language.", "release_date": "2026-09-18" }, { "name": "laya-english", "description": "Laya English checkpoint (ModernBERT-large), best accuracy on English text.", "release_date": "2026-09-18" }, { "name": "laya-multilingual", "description": "Laya multilingual checkpoint (mmBERT-base), 100+ languages, fastest.", "release_date": "2026-09-18" }, { "name": "jev-latest", "description": "Alias of laya-auto for drop-in Jev compatibility.", "release_date": "2026-09-18" } ] } ``` Listing models is free and counts towards your [rate limit](https://layahost.com/docs/rate-limits). --- # Errors layahost uses standard HTTP status codes and the same error bodies as the Jev API, so the Jev SDKs raise their usual exceptions. ## Error format Most errors have a `detail` object with a machine-readable `error_type` and a human-readable `message`: Response · 401 ```json { "detail": { "error_type": "authentication_error", "message": "Invalid or revoked API key." } } ``` Validation errors (`422`) use the FastAPI format instead: `detail` is a list with one entry per problem, and `loc` is the path to the field. See [Validation errors](#validation). Branch on the status code and `error_type`. Messages are for people and may change. ## Status codes | Status | `error_type` | Meaning | What to do | | --- | --- | --- | --- | | `401` | `authentication_error` | The API key is missing, invalid or revoked. | Check the `Authorization` header and the key. Don't retry. | | `402` | `insufficient_credits` | Your prepaid balance doesn't cover this request. | [Top up](https://layahost.com/billing). Don't retry until you have. | | `404` | `not_found_error` | There is no endpoint at this path. | Check the URL: paths start with `/v1/`, with no `/api` prefix. | | `405` | `invalid_request_error` | The endpoint exists but not for this HTTP method. | Use `POST` for `/v1/decide` and `/v1/systemone`, `GET` for the lists. | | `422` | (list) | The request body is invalid. | Fix the request. Don't retry. | | `429` | `rate_limit_error` | This key sent too many requests this minute. | Wait for the number of seconds in `retry-after`, then retry. | | `500` | `api_error` | An unexpected error on our side. | Retry with backoff. If it persists, [contact support](mailto:support@layahost.com) with the request ID. | | `503` | `api_error` | The inference backend is unavailable or failed. | Retry after `retry-after` seconds, with backoff. | | `529` | `overloaded_error` | All inference capacity is busy. | Retry after `retry-after` seconds, with backoff. | Requests rejected with `401`, `402`, `404`, `405`, `422`, `429`, `503` or `529` are not billed: if a request fails after it was charged, the charge is returned to your balance. ## Authentication errors A missing header and a bad key get different messages: Response · 401 ```json { "detail": { "error_type": "authentication_error", "message": "Missing API key. Send it as \"Authorization: Bearer \"." } } ``` See [Authentication](https://layahost.com/docs/authentication). ## Insufficient credits layahost is prepaid. When your balance can't cover a request, you get `402` with `error_type` `insufficient_credits` and a message with a link to the Billing page. A request is charged as a whole, so a `/v1/systemone` call with 10 questions needs credit for 10 decisions. We email you before you run out; set the threshold in Settings → Data & alerts. See [Pricing & billing](https://layahost.com/docs/pricing). ## Unknown endpoints and methods A path that doesn't exist gets `404`, and a known path with the wrong method gets `405`: Response · 404 ```json { "detail": { "error_type": "not_found_error", "message": "Unknown endpoint. See https://layahost.com/docs." } } ``` Response · 405 ```json { "detail": { "error_type": "invalid_request_error", "message": "Method not allowed for this endpoint." } } ``` ## Validation errors A body that is missing fields or has values of the wrong kind gets `422`. Each entry has `loc` (`"body"` followed by the path to the field), `msg` and `type`: Response · 422 ```json { "detail": [ { "loc": ["body", "type"], "msg": "The type field is required when template is not present.", "type": "value_error" }, { "loc": ["body", "question"], "msg": "The question field is required when template is not present.", "type": "value_error" } ] } ``` Problems inside a `/v1/systemone` question also carry the offending `input` and a `ctx` object with details, and `type` names the rule that failed: Response · 422 ```json { "detail": [ { "type": "too_short", "loc": ["body", "questions", "q", "choice", "criteria"], "msg": "Dictionary should have at least 2 items after validation, not 1", "input": {"yes": null}, "ctx": { "field_type": "Dictionary", "min_length": 2, "actual_length": 1 } } ] } ``` Option lists that don't fit the model's option budget (192 tokens for all names and descriptions of one question) also get `422`, on `["body", "questions", "", "criteria"]` for `/v1/systemone` and on `["body", "options"]` for `/v1/decide`. See [Limits](https://layahost.com/docs/systemone#limits). ## Rate limit errors Each key may send 600 requests per minute. Over that, you get `429` with a `retry-after` header: the number of seconds until the window resets. Response · 429 ```http HTTP/1.1 429 Too Many Requests content-type: application/json retry-after: 38 x-request-id: req_0t9rn7vlp9c6tl5rgaeetoc8 x-typesafe-request-id: req_0t9rn7vlp9c6tl5rgaeetoc8 { "detail": { "error_type": "rate_limit_error", "message": "Rate limit of 600 requests per minute exceeded." } } ``` See [Rate limits](https://layahost.com/docs/rate-limits). ## Server and capacity errors `503` (`api_error`) means the inference backend was unavailable or failed to answer. `529` (`overloaded_error`, message “Inference capacity is saturated, retry shortly.”) means all inference capacity is busy. Both are temporary and both come with `retry-after: 1`: Response · 503 ```http HTTP/1.1 503 Service Unavailable content-type: application/json retry-after: 1 x-request-id: req_guglin9qhthimpisgz5umdhp x-typesafe-request-id: req_guglin9qhthimpisgz5umdhp { "detail": { "error_type": "api_error", "message": "Inference backend is unavailable, retry shortly." } } ``` An unexpected error on our side gets `500` with `error_type` `api_error`, in the same format. ## Retries - Retry `429`, `503` and `529` after the number of seconds in `retry-after`, and back off exponentially if the error repeats. Retry `500` with backoff. - Don't retry `401`, `402`, `404`, `405` or `422`: the same request will fail again. - Retrying `429`, `503` and `529` is safe. Those requests were not billed and changed nothing. The Jev SDKs do this for you. By default, both retry `408`, `429` and every `5xx` status up to twice, and honour `retry-after`. There is a fetch-based retry loop in [Code examples](https://layahost.com/docs/code-examples). ## SDK exceptions | Status | Python (`typesafe_sdk`) | JavaScript (`@typesafe-ai/sdk`) | | --- | --- | --- | | `401` | `TypeSafeAuthenticationError` | `AuthenticationError` | | `402`, `405` | `TypeSafeAPIError` | `APIError` | | `404` | `TypeSafeNotFoundError` | `NotFoundError` | | `422` | `TypeSafeUnprocessableEntityError` | `UnprocessableEntityError` | | `429` | `TypeSafeRateLimitError` | `RateLimitError` | | `500`, `503`, `529` | `TypeSafeInternalServerError` | `InternalServerError` | Every class in the table extends the SDK's base API error, `TypeSafeAPIError` or `APIError`, which has the HTTP `status`, the parsed `body` and the request ID (`request_id` in Python, `requestId` in JavaScript). ## Request IDs Every response from `/v1/`, successful or not, carries a request ID such as `req_3rmqhpttolpzez0quj0bt42x` in two headers with the same value: `x-request-id`, and `x-typesafe-request-id` for the Jev SDKs. Successful decisions also include it in the body, as `request_id` on `/v1/decide` and `meta.request_id` on `/v1/systemone`. Requests that reach the model are listed in [Logs](https://layahost.com/logs) under the same ID. Include the ID when you [contact support](mailto:support@layahost.com). --- # Rate limits Each API key can send 600 requests per minute by default. Over that, requests get `429` until the minute is up. ## How the limit works - **Per key.** Every key has its own limit, shown on the [API keys](https://layahost.com/keys) page. - **Per request, not per question.** A `/v1/systemone` request with 32 questions counts once. Every authenticated request counts, including `GET /v1/models` and `GET /v1/templates`. - **Fixed one-minute window.** The window opens with the first request and resets 60 seconds later. - **Invalid keys don't count.** Requests rejected with `401` are not counted against any key. Responses don't include remaining-quota headers. Pace your client to stay under the limit and handle `429` when it happens. If you need a higher limit, [contact support](mailto:support@layahost.com): limits can be raised per key. ## When you hit the limit You get `429` with `error_type` `rate_limit_error`. The `retry-after` header says how many seconds remain until the window resets: Response · 429 ```http HTTP/1.1 429 Too Many Requests content-type: application/json retry-after: 38 x-request-id: req_0t9rn7vlp9c6tl5rgaeetoc8 x-typesafe-request-id: req_0t9rn7vlp9c6tl5rgaeetoc8 { "detail": { "error_type": "rate_limit_error", "message": "Rate limit of 600 requests per minute exceeded." } } ``` Wait that long, then retry. Rate-limited requests are not billed. The Jev SDKs retry `429` automatically and honour `retry-after`. ## Capacity errors The rate limit is separate from capacity. When the service is busy, a request within your limit can still get `529` `overloaded_error`, or `503` `api_error` if the backend is briefly unavailable. Both are temporary: retry after a short delay with backoff. See [Errors](https://layahost.com/docs/errors). ## Staying under the limit - **Batch questions.** If you ask several things about the same text, send them as up to 32 questions in one [`/v1/systemone`](https://layahost.com/docs/systemone) request. At 600 requests per minute, that allows up to 19,200 decisions per minute per key. Billing is still per question. - **Cap concurrency.** Run requests through a small worker pool instead of starting them all at once. See the batch example in [Code examples](https://layahost.com/docs/code-examples). - **Separate workloads.** Give batch jobs their own key so a backfill cannot use up the limit of your production traffic. - **Retry politely.** On `429`, wait for `retry-after`. Retrying immediately only fails again. --- # MCP & coding agents Three ways to put layahost in front of an AI agent: a remote MCP server for asking decisions from a chat or editor, a skill that teaches coding agents how to call the API in your code, and the whole documentation as Markdown. ## MCP server Endpoint ```http POST https://layahost.com/mcp ``` A remote MCP server (Streamable HTTP) on your normal account. Authenticate with an API key in the `Authorization` header; create a separate key for each agent so you can see its usage and revoke it on its own. Decisions are billed exactly like API calls and appear in [Logs](https://layahost.com/logs) as *MCP server*. | Tool | What it does | Cost | | --- | --- | --- | | `decide` | One decision about one text: a template or your own choice, yes/no or score question. | 1 decision | | `decide_batch` | The same decision for up to 64 texts. | 1 decision per text | | `list_templates` | The ready-made templates with their options. | Free | | `get_usage` | Balance, decisions left and the last 30 days of usage. | Free | ### Claude Code Terminal ```bash claude mcp add --transport http layahost https://layahost.com/mcp \ --header "Authorization: Bearer $LAYAHOST_API_KEY" ``` ### Cursor .cursor/mcp.json ```json { "mcpServers": { "layahost": { "url": "https://layahost.com/mcp", "headers": { "Authorization": "Bearer lh_your_key" } } } } ``` ### VS Code .vscode/mcp.json ```json { "servers": { "layahost": { "type": "http", "url": "https://layahost.com/mcp", "headers": { "Authorization": "Bearer ${input:layahost-key}" } } }, "inputs": [ { "id": "layahost-key", "type": "promptString", "description": "layahost API key", "password": true } ] } ``` Other clients work the same way: the URL above plus an `Authorization: Bearer` header. The server does not use OAuth, so clients that only support OAuth sign-in cannot connect yet. ## Agent skill The skill tells a coding agent (Claude Code, Codex, Cursor and others that read `SKILL.md`) which endpoint to use, how to phrase questions and options, how to read confidence and how to retry. With it, "add spam filtering to the contact form" ends in working layahost code instead of a guess. Install for Claude Code ```bash mkdir -p ~/.claude/skills/layahost curl -fsSL https://layahost.com/skills/layahost/SKILL.md -o ~/.claude/skills/layahost/SKILL.md ``` For a single project, put it in `.claude/skills/layahost/SKILL.md` in the repository instead. Other agents: save the [same file](https://layahost.com/skills/layahost/SKILL.md) wherever they load skills from. ## Docs as Markdown - [`/llms.txt`](https://layahost.com/llms.txt) lists every docs page with a one-line summary, following [llmstxt.org](https://llmstxt.org). - [`/llms-full.txt`](https://layahost.com/llms-full.txt) is the whole documentation in one file, for pasting into a context window. - Any docs page is available as Markdown by adding `.md`, for example [`/docs/decide.md`](https://layahost.com/docs/decide.md). --- # Code examples Complete examples you can copy and run. Each reads the API key from the `LAYAHOST_API_KEY` environment variable, or `TYPESAFE_API_KEY` for the Jev SDKs. - [Moderate comments before publishing](#moderate) - [Triage a support ticket with the Jev SDKs](#triage) - [Retry with backoff](#retries) - [Classify a batch within the rate limit](#batch) - [Laravel HTTP client](#laravel) ## Moderate comments before publishing Publish clean comments, reject clear abuse, and send the uncertain middle to a person. The thresholds are examples: tune them on your own comments. Moderation ```javascript async function moderate(comment) { const res = await fetch("https://layahost.com/v1/decide", { method: "POST", headers: { Authorization: `Bearer ${process.env.LAYAHOST_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ text: comment, template: "moderation" }), }); if (!res.ok) throw new Error(`layahost ${res.status}: ${await res.text()}`); const { probabilities } = await res.json(); if (probabilities.toxic >= 0.9) return "reject"; if (probabilities.toxic >= 0.5) return "review"; return "publish"; } console.log(await moderate("Thanks, this guide saved me hours!")); ``` Moderation ```python import os import requests def moderate(comment: str) -> str: res = requests.post( "https://layahost.com/v1/decide", headers={"Authorization": f"Bearer {os.environ['LAYAHOST_API_KEY']}"}, json={"text": comment, "template": "moderation"}, timeout=10, ) res.raise_for_status() toxic = res.json()["probabilities"]["toxic"] if toxic >= 0.9: return "reject" if toxic >= 0.5: return "review" return "publish" print(moderate("Thanks, this guide saved me hours!")) ``` Moderation ```php function moderate(string $comment): string { $client = new GuzzleHttp\Client(['base_uri' => 'https://layahost.com', 'timeout' => 10]); $res = $client->post('/v1/decide', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('LAYAHOST_API_KEY')], 'json' => ['text' => $comment, 'template' => 'moderation'], ]); $toxic = json_decode($res->getBody(), true)['probabilities']['toxic']; return match (true) { $toxic >= 0.9 => 'reject', $toxic >= 0.5 => 'review', default => 'publish', }; } echo moderate('Thanks, this guide saved me hours!'), PHP_EOL; ``` ## Triage a support ticket with the Jev SDKs Ask three questions about one ticket in a single `/v1/systemone` request: what the customer wants, how urgent it is, and whether they mention an order number. The answers are typed, so the SDKs know the labels for each question. Set `TYPESAFE_BASE_URL=https://layahost.com` and `TYPESAFE_API_KEY` first. Ticket triage ```python from typesafe_sdk import Choice, Noul, Score, TypeSafeClient ticket = "Order #48213 still hasn't shipped after 10 days. I need it for a wedding on Saturday!" with TypeSafeClient() as client: # reads TYPESAFE_BASE_URL and TYPESAFE_API_KEY result = client.system_one( model="laya-auto", state=ticket, questions={ "intent": Choice( instructions="What does the customer want?", criteria={ "delivery_status": "asks where an order is or when it arrives", "refund": "wants money back", "cancellation": "wants to cancel an order or account", "other": "anything else", }, ), "priority": Score( instructions="How urgent is this ticket?", criteria=["low", "normal", "high", "urgent"], ), "has_order_number": Noul(instructions="Does the message include an order number?"), }, ) levels = ["low", "normal", "high", "urgent"] print({ "intent": result.choices["intent"].choice, "priority": levels[round(result.scores["priority"].score)], "has_order_number": result.nouls["has_order_number"].noul >= 0.5, }) ``` Ticket triage ```javascript import { choice, noul, score, TypeSafeClient } from "@typesafe-ai/sdk"; const ticket = "Order #48213 still hasn't shipped after 10 days. I need it for a wedding on Saturday!"; const client = new TypeSafeClient(); // reads TYPESAFE_BASE_URL and TYPESAFE_API_KEY const { answers } = await client.systemOne({ model: "laya-auto", state: ticket, questions: { intent: choice("What does the customer want?", { delivery_status: "asks where an order is or when it arrives", refund: "wants money back", cancellation: "wants to cancel an order or account", other: "anything else", }), priority: score("How urgent is this ticket?", ["low", "normal", "high", "urgent"]), has_order_number: noul("Does the message include an order number?"), }, }); const levels = ["low", "normal", "high", "urgent"]; console.log({ intent: answers.intent.choice, priority: levels[Math.round(answers.priority.score)], has_order_number: answers.has_order_number.noul >= 0.5, }); ``` This request costs three decisions, $0.000015. See [POST /v1/systemone](https://layahost.com/docs/systemone) for every field. ## Retry with backoff Retry `429`, `503` and `529` after the number of seconds in `retry-after`, and fail fast on everything else. The Jev SDKs already do this; you only need it for plain HTTP calls. Retries ```javascript const RETRYABLE = new Set([429, 503, 529]); async function callLayahost(path, body, retries = 3) { for (let attempt = 0; ; attempt++) { const res = await fetch(`https://layahost.com${path}`, { method: "POST", headers: { Authorization: `Bearer ${process.env.LAYAHOST_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); if (res.ok) return res.json(); if (!RETRYABLE.has(res.status) || attempt === retries) { const requestId = res.headers.get("x-request-id") ?? "no request id"; throw new Error(`layahost ${res.status} (${requestId}): ${await res.text()}`); } const retryAfter = Number(res.headers.get("retry-after")); const delayMs = retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt; await new Promise((resolve) => setTimeout(resolve, delayMs)); } } const result = await callLayahost("/v1/decide", { text: "Where is my refund? It has been three weeks.", template: "support_intent", }); console.log(result.decision, result.request_id); ``` Retries ```python import os import time import requests RETRYABLE = {429, 503, 529} def call_layahost(path: str, body: dict, retries: int = 3) -> dict: for attempt in range(retries + 1): res = requests.post( f"https://layahost.com{path}", headers={"Authorization": f"Bearer {os.environ['LAYAHOST_API_KEY']}"}, json=body, timeout=10, ) if res.ok: return res.json() if res.status_code not in RETRYABLE or attempt == retries: request_id = res.headers.get("x-request-id", "no request id") raise RuntimeError(f"layahost {res.status_code} ({request_id}): {res.text}") retry_after = float(res.headers.get("retry-after", 0)) time.sleep(retry_after if retry_after > 0 else 0.5 * 2**attempt) result = call_layahost("/v1/decide", { "text": "Where is my refund? It has been three weeks.", "template": "support_intent", }) print(result["decision"], result["request_id"]) ``` ## Classify a batch within the rate limit Each key may send 600 requests per minute, which is 10 per second. For a batch of texts, space the requests out rather than starting them all at once. These examples allow at most 9 requests per second, with a few in flight at a time. Batch ```python import os import threading import time from concurrent.futures import ThreadPoolExecutor import requests MAX_PER_SECOND = 9 # the limit is 600 requests per minute per key lock = threading.Lock() next_slot = time.monotonic() def wait_for_slot() -> None: global next_slot with lock: now = time.monotonic() slot = max(now, next_slot) next_slot = slot + 1 / MAX_PER_SECOND time.sleep(slot - now) def sentiment(text: str) -> str: wait_for_slot() res = requests.post( "https://layahost.com/v1/decide", headers={"Authorization": f"Bearer {os.environ['LAYAHOST_API_KEY']}"}, json={"text": text, "template": "sentiment"}, timeout=10, ) res.raise_for_status() return res.json()["decision"] reviews = [ "Arrived on time and works perfectly.", "The battery died after two days.", "It does the job. Nothing special.", ] with ThreadPoolExecutor(max_workers=4) as pool: for text, label in zip(reviews, pool.map(sentiment, reviews)): print(f"{label:>8} {text}") ``` Batch ```javascript const MAX_PER_SECOND = 9; // the limit is 600 requests per minute per key let nextSlot = Date.now(); async function waitForSlot() { const now = Date.now(); const slot = Math.max(now, nextSlot); nextSlot = slot + 1000 / MAX_PER_SECOND; await new Promise((resolve) => setTimeout(resolve, slot - now)); } async function sentiment(text) { await waitForSlot(); const res = await fetch("https://layahost.com/v1/decide", { method: "POST", headers: { Authorization: `Bearer ${process.env.LAYAHOST_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ text, template: "sentiment" }), }); if (!res.ok) throw new Error(`layahost ${res.status}: ${await res.text()}`); return (await res.json()).decision; } const reviews = [ "Arrived on time and works perfectly.", "The battery died after two days.", "It does the job. Nothing special.", ]; const labels = await Promise.all(reviews.map(sentiment)); reviews.forEach((text, i) => console.log(labels[i].padStart(8), text)); ``` A batch of 10,000 texts takes about 19 minutes at this pace. If you ask several questions about each text, send them together in one `/v1/systemone` request: the rate limit counts requests, not questions. ## Laravel HTTP client In a Laravel app, add the key to `config/services.php` as `'layahost' => ['key' => env('LAYAHOST_API_KEY')]`, then use the HTTP client with retries on temporary errors: Laravel ```php use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; $result = Http::withToken(config('services.layahost.key')) ->baseUrl('https://layahost.com') ->timeout(10) ->retry(3, 1000, fn ($e) => $e instanceof RequestException && in_array($e->response->status(), [429, 503, 529])) ->post('/v1/decide', [ 'text' => 'Where is my refund? It has been three weeks.', 'template' => 'support_intent', ]) ->json(); echo $result['decision']; ``` With `retry()`, a request that still fails after the last attempt throws `RequestException`. The fixed one-second wait suits `503` and `529`; a `429` can ask you to wait up to a minute. --- # Pricing & billing $5 per million decisions, or $0.000005 each. You buy prepaid credit and every decision is deducted from it. There is no subscription. New accounts get $1 of credit (200K decisions) when they verify their email, so you can test on your own data before paying. ## What counts as a decision A decision is one answered question: - [`POST /v1/decide`](https://layahost.com/docs/decide) is always one decision, including every template. - [`POST /v1/systemone`](https://layahost.com/docs/systemone) is one decision per question. A request with 3 questions is 3 decisions, $0.000015. - Runs in the [Playground](https://layahost.com/playground) are billed the same way. The price is the same for every model and does not depend on the length of the text. Answers served from the cache are billed like any other. **Not billed:** requests rejected with `401`, `402`, `404`, `405`, `422`, `429`, `503` or `529`, and `GET /v1/models` and `GET /v1/templates`. | Workload | Decisions | Cost | | --- | --- | --- | | One moderation check | 1 | $0.000005 | | One `/v1/systemone` request with 3 questions | 3 | $0.000015 | | 100,000 support tickets, 3 questions each | 300,000 | $1.50 | | 1,000,000 comments, one moderation check each | 1,000,000 | $5.00 | ## Add credit Top up on the [Billing](https://layahost.com/billing) page. You pay with Stripe and get an invoice by email; you can add a VAT ID at checkout. Credit is added as soon as Stripe confirms the payment, and it doesn't expire while your account is active. Choose a preset or any whole amount from $10 to $5,000. Larger top-ups include bonus credit: | You pay | Bonus | Credit | Decisions | | --- | --- | --- | --- | | $10 | – | $10 | 2,000,000 | | $25 | – | $25 | 5,000,000 | | $100 | +10% | $110 | 22,000,000 | | $500 | +20% | $600 | 120,000,000 | The bonus applies to custom amounts too: +10% from $100, +20% from $500. The smallest top-up, $10, buys 2,000,000 decisions. ## Track your spend - Every response states its cost: `usage.cost_micros` and `usage.cost_usd` on `/v1/decide`, and `meta.decisions`, `meta.cost_micros` and `meta.cost_usd` on `/v1/systemone`. `cost_micros` is an integer in millionths of a dollar (5 per decision), so use it for exact accounting; `cost_usd` is the same amount as a floating-point number. - The [Billing](https://layahost.com/billing) page shows your balance, your spend over the last 30 days and how long your balance lasts at that rate. - [Logs](https://layahost.com/logs) list the cost of each request. ## Low balance and an empty balance We email you once when your balance drops below a threshold, $1.00 by default. Change it, or set it to 0 to turn the email off, in Settings → Data & alerts. The alert re-arms when a top-up takes you back above the threshold. When your balance can't cover a request, the API answers `402` with `error_type` `insufficient_credits` and doesn't process the request. A request is charged as a whole, so a `/v1/systemone` request with 10 questions needs credit for 10 decisions. Requests work again as soon as you top up; your keys don't change. --- # Data & privacy By default we keep the verdicts and metadata of your requests for 7 days, not the text you send. We never use your data to train models. ## What we store | Data | Kept for | Notes | | --- | --- | --- | | Request metadata: time, endpoint, API key, model, template, status, decisions, input tokens, cost, latency, cache hit, error message | 7 days | Shown in [Logs](https://layahost.com/logs). | | Verdicts: for each question, the chosen option, yes probability or score, and the confidence | 7 days | Not the full probability distributions. | | Request text | 7 days, only if you opt in | Off by default. The first 2,000 characters. | | Playground text | 7 days | Always kept, so you can see what you tried. | | Daily usage totals: decisions, tokens, cost, latency, errors | Until you delete your account | Aggregated per day and key, for the dashboard and billing. | Logs older than 7 days are deleted by a daily job. ## Request text is opt-in We don't store the text or state you send unless you turn on **Store request text in logs** in Settings → Data & alerts. It helps when you debug decisions from the Logs page. You can turn it off at any time, and **Delete stored text now** removes the stored text from all of your log entries at once. Text you enter in the [Playground](https://layahost.com/playground) is always kept with its log entry for 7 days. ## How requests are processed Your text is processed in memory to compute the answer and is not written to disk unless you opted in as above. To answer repeated requests faster, recent answers are kept in memory for up to an hour, keyed by a hash of the model, text and questions. The cache holds the answers, not your text. On `/v1/systemone`, `"cache": false` skips the cache for that request: its answer is neither read from nor stored in it. ## Never used for training We don't use your requests, text or answers to train or fine-tune models, and we don't sell your data. ## Keys and payments - API keys are stored as hashes. We show each key once, when you create it, and can't show it again. - Payments are handled by Stripe. Card details go to Stripe and never reach our servers. ## More The [Privacy Policy](https://layahost.com/privacy) covers account and billing data, processors and your rights. You can delete your account in Settings.