ZAIA by ZCLOUD

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

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 backendZAIA
Field definitions, context, conversation historyAPI keys and tenants
Parameter validation and business rulesCalling the model and checking its output
Searching your own data and authorizing the userMonthly quotas and concurrency limit
Choosing the records that become sourcesChecking that cited sources were in the input
Displaying the answer and source cardsStores 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: 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):

{"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.

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 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 (runs in the browser only). You can try requests and responses with a test key in the playground.

PropertyRule
nameunique, ^[A-Za-z][A-Za-z0-9_]{0,49}$
typestring, integer, boolean, date, stringArray
descriptionrequired, at most 500 characters
requiredboolean
allowedValuesoptional, 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:

PropertyLimit
idunique, up to 100 characters
titleup to 200 characters
textup to 4000 characters; the total of all source texts at most 40,000 characters
urlabsolute HTTPS, up to 2048 characters, without credentials
updatedAtoptional

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 text

PHP 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.

StatusMeaningWhat to do
400validation_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.
401unauthorized: unknown, revoked or disabled key (one generic response)Check the key and the tenant, do not retry in a loop.
413request_too_large: body over 128 KiBSend fewer or shorter sources.
429concurrency_limit or quota_exceededWait for the Retry-After header (for quota until the next UTC month), then retry.
502Provider error or invalid model output (provider_error, provider_invalid_response, provider_refused)Show fallback content. The attempt may have been counted.
503provider_unavailable or service_unavailable (database error, also during key lookup)Show fallback content, try again later with a delay.
504provider_timeoutDo 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 /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