Implementation guide
Connecting a system (city portal, e-shop, helpdesk) is an adapter in your backend. It never requires a change in ZAIA itself. The examples use the products domain.
Contents
- Who is responsible for what
- Step 0: key and tenant
- Test keys
- Client libraries
- Step 1: field definitions
- Step 2: context, history, language
- Step 3: your own search
- Step 4: sources and rendering
- Request examples
- curl and C#
- PHP quick start
- When to skip interpretation
- Errors
- Response headers
- Key rotation and usage
- Checklist
- Reference adapters
Who is responsible for what
ZAIA never decides who may see a record and never fetches source URLs. Connecting a new system is an adapter in your backend, not a change in the API.
| Your backend | ZAIA |
|---|---|
| Field definitions, context, conversation history | API keys and tenants |
| Parameter validation and business rules | Calling the model and checking its output |
| Searching your own data and authorizing the user | Monthly quotas and concurrency limit |
| Choosing the records that become sources | Checking that cited sources were in the input |
| Displaying the answer and source cards | Stores nothing: no questions, sources or answers |
Step 0: provisioning access
Access is provisioned by the operator, not by the integrating application, using an admin key (the X-Admin-Key header, set in the ZAIA configuration, at least 32 characters).
POST /admin/tenants
{"name":"Acme Shop","monthlyRequestLimit":20000}
POST /admin/tenants/{id}/keys
{"name":"backend production"}The limit is 1 to 1,000,000 requests per month. The response to key creation contains the raw key only once; only its SHA-256 hash is stored.
Store the key in your backend's secret manager and use it only on the server as Authorization: Bearer <key>. Never put it in a browser or a mobile app. Do not use the admin key from an application at all.
Test keys
For development and CI use a test key. The operator creates it with the same endpoint and the test flag:
POST /admin/tenants/{id}/keys
{"name":"integration test","test":true}Response (201):
{"id":"<guid>","key":"aia_test_<43 characters>","prefix":"aia_test_Ab3","name":"integration test","test":true}Live keys look like tvm_... and have "test":false. The mode is determined by the stored key, not by its prefix. GET /admin/tenants/{id}/keys returns id, name, prefix, test and revokedAt for every key.
With a test key, /v1/interpretations and /v1/answers perform full input validation (the same 400 with detail and field) and return a deterministic response:
- no model call, no quota consumption and no usage record,
- the
X-Test-Mode: trueheader, noX-Quota-*headers,X-Request-Idstays, usage:{"inputTokens":0,"outputTokens":0,"known":true}.
Interpretation: status ready, parameters contain every declared field with a sample value. string: the first allowedValue, otherwise "test". integer: 1. boolean: true. date: today's UTC date in the yyyy-MM-dd format. stringArray: [first allowedValue], otherwise ["test"]. A question containing [clarify] returns status needs_clarification, parameters {} and clarification "Test mode: please clarify the question." (for locale cs-CZ the Czech "Testovací režim: upřesněte prosím dotaz.").
Answer: status answered, answer "Test answer from source: {title of the first source}." (for locale cs-CZ the Czech "Testovací odpověď z podkladu: {title}."), sources contain only the first source. A question containing [insufficient] or empty sources returns insufficient_sources with the usual localized text and sources [].
GET /v1/usage with a test key returns zero usage (only monthlyRequestLimit is kept), X-Test-Mode: true and no X-Quota-* headers. A test key also works when the monthly quota is exhausted. A revoked test key or a disabled tenant returns 401.
Example of an interpretation response in test mode for the fields city (string, allowedValues ["Prague","Brno"]), limit (integer), open (boolean), from (date) and tags (stringArray):
{"requestId":"...","status":"ready","parameters":{"city":"Prague","limit":1,"open":true,"from":"2026-10-29","tags":["test"]},"clarification":null,"usage":{"inputTokens":0,"outputTokens":0,"known":true}}Recommended workflow: development and CI run against a test key. In production, switch only the secret to a live key, with no code change.
Client libraries
The client library for PHP and C# is a single file with no dependencies. Copy it into your project, nothing else to install.
- PHP 8.1+, needs only
ext-curlandext-json: clients/AiAsistentClient.php - .NET (net8.0+), BCL only: clients/AiAsistentClient.cs
The key belongs on the server only. In development and CI use a test key aia_test_..., in production switch only the secret.
PHP
require __DIR__ . '/AiAsistentClient.php';
use AiAsistent\{Client, Field};
$aia = new Client('https://ai.example.invalid', getenv('AIA_KEY'));
// 1. Turn the question into parameters.
$r = $aia->interpret($_GET['q'], [
Field::string('category', 'Product category', true, ['outdoor', 'books']),
Field::integer('maxPrice', 'Maximum price in CZK'),
], ['context' => ['currency' => 'CZK']]);
if ($r->needsClarification()) { exit(htmlspecialchars($r->clarification())); }
// 2. Your own search (validate $r->parameters(), apply your authorization).
$sources = my_search($r->parameters()); // [['id' => ..., 'title' => ..., 'text' => ..., 'url' => 'https://...'], ...]
// 3. Answer from those sources.
$a = $aia->answer($_GET['q'], $sources);
echo '<p>' . htmlspecialchars($a->answer()) . '</p>'; // plain text, always escape
foreach ($a->sources() as $s) {
echo '<a href="' . htmlspecialchars($s['url']) . '">' . htmlspecialchars($s['title']) . '</a>';
}C#
using AIAsistent.Client;
var ai = new AiAsistentClient(new Uri("https://ai.example.invalid"), apiKey);
var q = await ai.InterpretAsync(new("Tent under 3000 CZK in stock?",
[
Field.String("category", "Product category.", true, "outdoor", "books"),
Field.Integer("maxPrice", "Maximum price in CZK."),
]) { Context = new { currency = "CZK" } });
if (q.NeedsClarification) return Ask(q.Clarification!);
var hits = MySearch(q.Get<string>("category"), q.Get<int?>("maxPrice")); // your data, your authorization rules
var a = await ai.AnswerAsync(new(question, hits.Select(h => new Source(h.Id, h.Title, h.Text, h.Url)).ToList()));
Render(a.IsAnswered ? a.Answer : "Sorry, nothing found."); // plain text, escape it; a.Sources = cited cardsErrors and retries
A failed response throws an exception: PHP ApiException (fields status, errorCode, detail, field, traceId, retryAfter), C# AiAsistentException (fields StatusCode, Code, Detail, Field, TraceId, RetryAfter). PHP reports a network error or timeout as TransportException. The clients do no automatic retries. A timeout or outage on a paid call is ambiguous, the model may have processed and billed the request, so do not retry it blindly. On 502 or 503 retry only deliberately and with a limit. The default timeout is 40 s, the model time limit on the server is 30 s.
Step 1: field definitions
Describe the filters of your search as fields (1 to 20 items).
A draft from a sample record of your database is prepared by the field definition helper (runs in the browser only). You can try requests and responses with a test key in the playground.
| Property | Rule |
|---|---|
name | unique, ^[A-Za-z][A-Za-z0-9_]{0,49}$ |
type | string, integer, boolean, date, stringArray |
description | required, at most 500 characters |
required | boolean |
allowedValues | optional, 1 to 100 distinct strings of at most 100 characters each, only for string and stringArray |
Dates are returned in the yyyy-MM-dd format. For closed sets of values always use allowedValues. The canonical allowed value is returned; differences in letter case and Czech diacritics in the model output are tolerated (Kultura is returned as kultura).
Step 2: context, history, language
questionis required, non-empty, at most 2000 characters.context: a JSON object, at most 8000 serialized characters. Send facts the model cannot know (currency, time zone, allowed dates, the user's city). It is treated as data, not as instructions.history(optional): at most 10 items, roleuserorassistant, content up to 2000 characters.locale: one of 17 supported values,cs-CZ,sk-SK,en-US,de-DE,pl-PL,uk-UA,fr-FR,es-ES,it-IT,pt-BR,ru-RU,tr-TR,ar-SA,zh-CN,ja-JP,ko-KR,id-ID. The default iscs-CZ. A regional variant (e.g.de-AT) resolves to the supported locale of its language, any other value returns 400validation_error.- The whole request body is limited to 128 KiB.
Step 3: your own search with authorization
Validate the returned parameters first (ranges, ownership, business rules), then query your own database. Apply your own visibility rules (published, in stock, belongs to this user). The model's response is never an authorization decision. Do not send unpublished, hidden or cancelled records.
Step 4: records as sources and rendering
Send at most 20 records as sources:
| Property | Limit |
|---|---|
id | unique, up to 100 characters |
title | up to 200 characters |
text | up to 4000 characters; the total of all source texts at most 40,000 characters |
url | absolute HTTPS, up to 2048 characters, without credentials |
updatedAt | optional |
Render the answer field as escaped plain text and never treat it as HTML. Show the returned sources as cards linking to your own pages. The answer itself contains no links.
Complete examples (products domain)
POST /v1/interpretations
Request:
POST /v1/interpretations
Authorization: Bearer <tenant key>
Content-Type: application/json
{
"question": "Do you have a tent under 3000 CZK in stock?",
"context": { "currency": "CZK" },
"fields": [
{ "name": "category", "type": "string", "description": "Product category.", "required": true, "allowedValues": ["outdoor", "books"] },
{ "name": "maxPrice", "type": "integer", "description": "Maximum price in CZK.", "required": false },
{ "name": "inStock", "type": "boolean", "description": "Only items in stock.", "required": false }
]
}Response. status is ready or needs_clarification; usage carries the token counts from the provider, known is false if the provider did not report them.
{
"requestId": "...",
"status": "ready",
"parameters": { "category": "outdoor", "maxPrice": 3000, "inStock": true },
"clarification": null,
"usage": { "inputTokens": 812, "outputTokens": 64, "known": true }
}With needs_clarification, clarification contains a question for the user and required parameters may be missing. Fields are returned with the declared types, but your backend must still re-validate and enforce them.
POST /v1/answers
Request with the records your search returned for the given parameters:
POST /v1/answers
Authorization: Bearer <tenant key>
Content-Type: application/json
{
"question": "Do you have a tent under 3000 CZK in stock?",
"sources": [
{ "id": "tent", "title": "Family tent", "text": "Family tent: Four-person tent for family trips. Price 2490 CZK.", "url": "https://shop.example/products/tent", "updatedAt": "2026-09-30T08:00:00+00:00" }
]
}Response. status is answered or insufficient_sources; sources are only the input records the model cited.
{
"requestId": "...",
"status": "answered",
"answer": "Yes, the Family tent is available for 2490 CZK.",
"sources": [
{ "id": "tent", "title": "Family tent", "url": "https://shop.example/products/tent", "updatedAt": "2026-09-30T08:00:00+00:00" }
],
"usage": { "inputTokens": 640, "outputTokens": 48, "known": true }
}An empty sources array returns a deterministic localized insufficient_sources response, with no model call and no quota consumption. Source membership is verified, but that does not prove that every sentence is supported by the cited text.
curl and C#
The examples are server-side. Download the ready-made set of requests as a Postman collection; the guide is also available as Markdown and in llms.txt for AI agents. Replace the address https://ai.example.invalid with the address you receive when access is provisioned.
curl -sS https://ai.example.invalid/v1/answers \
-H "Authorization: Bearer $AIA_KEY" \
-H "Content-Type: application/json" \
-d '{"question":"Do you have a tent?","sources":[]}'using System.Net.Http.Headers;
using System.Net.Http.Json;
// Server-side only: the key comes from a secret store, never from a client app.
var http = new HttpClient
{
BaseAddress = new Uri("https://ai.example.invalid/"),
// Must exceed the service's own 30 s model timeout.
Timeout = TimeSpan.FromSeconds(40)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("AIA_KEY"));
var response = await http.PostAsJsonAsync("v1/answers", new
{
question = "Do you have a tent under 3000 CZK in stock?",
sources = new[]
{
new { id = "tent", title = "Family tent",
text = "Family tent: Four-person tent. Price 2490 CZK.",
url = "https://shop.example/products/tent" }
}
});
if (!response.IsSuccessStatusCode)
{
// ProblemDetails: log "code" and "traceId", never the key.
Console.Error.WriteLine(await response.Content.ReadAsStringAsync());
return;
}
var json = await response.Content.ReadAsStringAsync(); // render "answer" as escaped plain textPHP quick start
No library, just cURL. The client library is simpler. Plain PHP 8 with the ext-curl extension, no dependencies. Server-side only, the key never goes to the browser. Flow: interpretation, your own search (placeholder function my_search), answer.
<?php
// Server-side only: the key comes from a secret store / environment, never from a client.
const AIA_BASE = 'https://ai.example.invalid';
function aia_post(string $path, array $body): array
{
$headers = [];
$ch = curl_init(AIA_BASE . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('AIA_KEY'),
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
// Must exceed the service's own 30 s model timeout.
CURLOPT_TIMEOUT => 40,
// Collect response headers (X-Quota-Remaining, Retry-After).
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
if (str_contains($line, ':')) {
[$k, $v] = explode(':', $line, 2);
$headers[strtolower(trim($k))] = trim($v);
}
return strlen($line);
},
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($raw === false) {
// A timeout is ambiguous (the call may have been billed): do not retry blindly.
throw new RuntimeException('ZAIA network error or timeout');
}
return [$status, json_decode($raw, true) ?? [], $headers];
}
function aia_call(string $path, array $body): array
{
[$status, $json, $headers] = aia_post($path, $body);
if (isset($headers['x-quota-remaining'])) {
error_log('ZAIA quota remaining: ' . $headers['x-quota-remaining']);
}
if ($status === 429) {
// concurrency_limit or quota_exceeded: wait for Retry-After, no tight loop.
throw new RuntimeException('ZAIA 429, retry after ' . ($headers['retry-after'] ?? '?') . ' s');
}
if ($status === 400) {
// validation_error: "detail" is the message, "field" the JSON path of the bad input.
throw new InvalidArgumentException(($json['detail'] ?? 'invalid request') . ' [' . ($json['field'] ?? '?') . ']');
}
if ($status >= 400) {
// Log "code" and "traceId" (also in X-Request-Id), never the key.
throw new RuntimeException('ZAIA error ' . $status . ' ' . ($json['code'] ?? '') . ' ' . ($json['traceId'] ?? ''));
}
return $json;
}
$question = 'Do you have a tent under 3000 CZK in stock?';
// 1) Interpretation: question to typed parameters.
$interp = aia_call('/v1/interpretations', [
'question' => $question,
'context' => ['currency' => 'CZK'],
'fields' => [
['name' => 'category', 'type' => 'string', 'description' => 'Product category.', 'required' => true, 'allowedValues' => ['outdoor', 'books']],
['name' => 'maxPrice', 'type' => 'integer', 'description' => 'Maximum price in CZK.', 'required' => false],
],
]);
// 2) Your own search with your own authorization (placeholder).
// Validate $interp['parameters'] first; send only records the user may see.
$sources = my_search($interp['parameters'] ?? []); // up to 20 records {id, title, text, url}
// 3) Answer grounded in those sources.
$answer = aia_call('/v1/answers', ['question' => $question, 'sources' => $sources]);
// Render as escaped plain text, never as HTML.
echo htmlspecialchars($answer['answer'] ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');When to skip interpretation
If your system already knows what to search for (a form with filters, text search in an FAQ), do not call POST /v1/interpretations. Run the search and call POST /v1/answers directly. You save one paid operation per question. The faq domain in the sample works this way.
Errors
Errors are ProblemDetails with a stable code and traceId. Log the traceId, never the key.
| Status | Meaning | What to do |
|---|---|---|
| 400 | validation_error or invalid_request: invalid input (limits, duplicate field names, bad source URL, null items, malformed JSON). Contains detail (English message) and field (JSON path of the bad input, e.g. sources[4].url) | Fix the request. Do not repeat it unchanged. |
| 401 | unauthorized: unknown, revoked or disabled key (one generic response) | Check the key and the tenant, do not retry in a loop. |
| 413 | request_too_large: body over 128 KiB | Send fewer or shorter sources. |
| 429 | concurrency_limit or quota_exceeded | Wait for the Retry-After header (for quota until the next UTC month), then retry. |
| 502 | Provider error or invalid model output (provider_error, provider_invalid_response, provider_refused) | Show fallback content. The attempt may have been counted. |
| 503 | provider_unavailable or service_unavailable (database error, also during key lookup) | Show fallback content, try again later with a delay. |
| 504 | provider_timeout | Do not retry blindly. |
Example of a 400 error body:
{ "type": "about:blank", "title": "Invalid request", "status": 400,
"detail": "source URL must be an absolute HTTPS URL without credentials.",
"code": "validation_error", "field": "sources[4].url", "traceId": "0HN..." }ZAIA does not retry paid operations itself and does not offer idempotency. A timeout is ambiguous: the provider may have processed and billed the request, and failed calls keep their quota reservation. Retry 502 and 504 only deliberately, with a limit, never in an automatic loop. Your own HTTP timeout should be longer than the service's 30-second model timeout.
Response headers
X-Request-Id: on every/v1response, identical torequestId/traceIdin the body. Quote it when contacting support.X-Quota-Limit,X-Quota-Remaining,X-Quota-Reset: on/v1/interpretationsand/v1/answersresponses where a quota reservation took place (including errors and 429quota_exceeded), and onGET /v1/usage(not with a test key).X-Quota-Resetis the start of the next UTC month in ISO 8601 format (2026-11-01T00:00:00Z). Responses with emptysources(no model call) carry no quota headers.
Key rotation and usage
- Create a second key (
POST /admin/tenants/{id}/keys). - Switch the calling backend to the new key.
- Revoke the old key:
DELETE /admin/tenants/{id}/keys/{keyId}. Revocation takes effect immediately.
A tenant can be disabled via PATCH /admin/tenants/{id} with {"enabled":false}. The current monthly (UTC) counters are returned by GET /v1/usage.
Integration checklist
- Tenant created, key issued once and stored on the server in a secret manager.
- Fields defined within the limits,
allowedValuesused for closed sets. - Context carries the time zone, language-dependent data and anything the model cannot know.
- Interpretation skipped when the filters are already known.
- Interpreted parameters are validated by the backend and business rules enforced.
- The search applies authorization and visibility (unpublished, hidden and cancelled records are never sent).
- At most 20 sources, text up to 4000 characters, absolute HTTPS URL on your domain.
- Answer rendered as escaped plain text, source cards built from the returned
sources. - Empty result,
needs_clarificationandinsufficient_sourceshave a path for the user. - Error table implemented, paid timeouts are not blindly retried.
- Manual quality check done over the evaluation set (never run automatically).
Reference adapters
The sample samples/IntegrationDemo shows three different domains on the same unchanged API: city events (dates, time zone, cancellations), products (enums and availability) and FAQ (free text, no interpretation, an unpublished article is never sent). Offline run:
dotnet run --project samples/IntegrationDemo -- --offline --domain faq
dotnet run --project samples/IntegrationDemo -- --offline --domain events
dotnet run --project samples/IntegrationDemo -- --offline --domain products