# Implementation guide - ZAIA

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.

Web: https://zaia.cz/en/ | HTML version: https://zaia.cz/en/guide.html | Postman: https://zaia.cz/AIAsistent.postman_collection.json | Čeština: https://zaia.cz/navod.md

## 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):

```json
{"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: true` header, no `X-Quota-*` headers, `X-Request-Id` stays,
- `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):

```json
{"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-curl` and `ext-json`: [clients/AiAsistentClient.php](https://zaia.cz/clients/AiAsistentClient.php)
- .NET (net8.0+), BCL only: [clients/AiAsistentClient.cs](https://zaia.cz/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

```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#

```csharp
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 cards
```

### Errors 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](https://zaia.cz/en/fields.html) (runs in the browser only). You can try requests and responses with a test key in the [playground](https://zaia.cz/en/playground.html).

| 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

- `question` is 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, role `user` or `assistant`, 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 is `cs-CZ`. A regional variant (e.g. `de-AT`) resolves to the supported locale of its language, any other value returns 400 `validation_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:

```http
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.

```json
{
  "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:

```http
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.

```json
{
  "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 (https://zaia.cz/AIAsistent.postman_collection.json). Replace the address `https://ai.example.invalid` with the address you receive when access is provisioned.

```bash
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":[]}'
```

```csharp
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 text
```

## PHP quick start

No library, just cURL. The [client library](#client-libraries) 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
<?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:

```json
{ "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 `/v1` response, identical to `requestId`/`traceId` in the body. Quote it when contacting support.
- `X-Quota-Limit`, `X-Quota-Remaining`, `X-Quota-Reset`: on `/v1/interpretations` and `/v1/answers` responses where a quota reservation took place (including errors and 429 `quota_exceeded`), and on `GET /v1/usage` (not with a test key). `X-Quota-Reset` is the start of the next UTC month in ISO 8601 format (`2026-11-01T00:00:00Z`). Responses with empty `sources` (no model call) carry no quota headers.

## Key rotation and usage

1. Create a second key (`POST /admin/tenants/{id}/keys`).
2. Switch the calling backend to the new key.
3. 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, `allowedValues` used 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_clarification` and `insufficient_sources` have 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
```
