Search by

Framework-agnostic chatbot tool-server core: tools, data sources, JSON-schema generation and result DTOs

Package info

github.com/neika-ai/sdk

pkg:composer/neika-ai/sdk

Statistics

Installs: 8

Dependents: 2

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-11-06 12:12 UTC

This package is not auto-updated.

Last update: 2026-11-07 10:13:34 UTC


README

Framework-agnostic PHP core for the Neika chatbot. PHP 8.1+, MIT.

Symfony users: use neika-ai/symfony-bundle. Sylius users: use neika-ai/sylius-plugin.

Features

Install

composer require neika-ai/sdk

Tools and data sources

A tool is one class plus one arguments DTO:

use Neika\Sdk\Contract\ChatbotToolInterface;
use Neika\Sdk\DTO\ContentItem;
use Neika\Sdk\DTO\ToolCallContext;
use Neika\Sdk\DTO\ToolDefinition;
use Neika\Sdk\DTO\ToolResult;

class GreetTool implements ChatbotToolInterface
{
    public function getDefinition(): ToolDefinition
    {
        return new ToolDefinition('greet', 'app.chatbot.greet.description');
    }

    public function getArgumentsClass(): string
    {
        return GreetArguments::class;
    }

    public function execute(object $arguments, ToolCallContext $context): ToolResult
    {
        return new ToolResult([new ContentItem('Hello ' . $arguments->name)]);
    }
}

ArgumentsSchemaGenerator turns the DTO's symfony/validator constraints into a JSON Schema:

use Symfony\Component\Validator\Constraints as Assert;

class GreetArguments
{
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Length(max: 64)]
        public string $name = '',
    ) {
    }
}

// $generator->generate(GreetArguments::class)
// => ['type' => 'object',
//     'properties' => ['name' => ['type' => 'string', 'maxLength' => 64]],
//     'required' => ['name'], 'additionalProperties' => false]

For choices loaded at runtime (from the DB, say), put #[ToolChoice(loader: RegionLoader::class)] on the property and implement ToolChoiceLoaderInterface. The schema lists the loaded values as its enum.

A data source implements ChatbotDataSourceInterface and returns a DocumentPage of SourceDocuments. Page through it with CursorCodec; HtmlToText turns your HTML into plain text.

Names must be unique. The registries look tools and sources up by getDefinition()->name, and the first match wins. The Symfony bundle fails the container build on a duplicate. Standalone, keep them distinct yourself.

Personal data in tools

Never put personal data in the text the model reads. Write a slot and send the value next to it:

use Neika\Sdk\DTO\ContentItem;
use Neika\Sdk\DTO\PrivateValue;
use Neika\Sdk\DTO\ToolResult;

return new ToolResult(
    [new ContentItem('Order {{customer_order_number}} for {{customer_full_name}} shipped.')],
    privateValues: [
        new PrivateValue('customer_order_number', (string) $order->getNumber()),
        new PrivateValue('customer_full_name', $customer->getFullName()),
    ],
);

Name the slot so the model knows what it is. customer_full_name, not name. Use lowercase letters, digits and underscores, start with a letter, and stay within 64 characters. A value with any other name is skipped.

With a translated message, pass the slot as the parameter:

$this->translator->trans('order.summary', ['%name%' => '{{customer_full_name}}'], locale: $context->locale);

The customer sees Jan Novák. A model hosted outside the EU sees {{customer_full_name_1}}. It can pass that to another tool, and that tool gets the real value.

Send Where
Names, emails, phones, addresses, tracking codes, order numbers privateValues
Order state, totals, item counts, product names The text
Anything only the customer needs to see blocks

No value, no slot. If a customer has no tracking code, leave that sentence out. A slot with no value reaches the model as plain text. If its name ends in a number, like {{address_1}}, it's removed from the reply.

Form fields need nothing. That model never sees text and email values, and your tool still gets the real ones. Set placeholder on the field to give the slot a clearer name than the field name:

new FormField('orderNumber', 'order.number', FormFieldType::Text, true, placeholder: 'customer_order_number');

Mark arguments that carry personal data. Put #[Sensitive] on the property. That model sees a placeholder, and your tool still gets the real value:

use Neika\Sdk\Validator\Sensitive;

#[Assert\NotBlank]
#[Assert\Email]
#[Sensitive]
public string $email = '',

Unmarked arguments reach the model as typed. Mark contact data only: emails, phones, names, addresses, customer and order numbers.

Standalone setup

Without a framework, you bring auth, routing and argument deserialization. The SDK does the rest.

  1. Wire the registries. Each takes a plain list of services:
use Neika\Sdk\Registry\DataSourceRegistry;
use Neika\Sdk\Registry\ToolChoiceLoaderRegistry;
use Neika\Sdk\Registry\ToolRegistry;
use Neika\Sdk\Schema\ArgumentsSchemaGenerator;
use Neika\Sdk\Validator\ToolChoiceValidator;
use Symfony\Component\Validator\ConstraintValidatorFactory;
use Symfony\Component\Validator\Validation;

$toolRegistry   = new ToolRegistry([new GreetTool()]);
$sourceRegistry = new DataSourceRegistry([]);         // ChatbotDataSourceInterface
$choiceLoaders  = new ToolChoiceLoaderRegistry([]);   // ToolChoiceLoaderInterface
$schema         = new ArgumentsSchemaGenerator($choiceLoaders);
$validator      = Validation::createValidatorBuilder()
    ->enableAttributeMapping()
    ->setConstraintValidatorFactory(new ConstraintValidatorFactory([
        ToolChoiceValidator::class => new ToolChoiceValidator($choiceLoaders),
    ]))
    ->getValidator();

Without that validator factory, validating a #[ToolChoice] argument throws ArgumentCountError.

  1. GET /chatbot/v1/tools lists the tools with their input schemas:
$out = [];
foreach ($toolRegistry->all() as $tool) {
    $argumentsClass = $tool->getArgumentsClass();
    $out[] = $tool->getDefinition()
        ->withInputSchema($schema->generate($argumentsClass))
        ->withSensitiveArguments($schema->listSensitiveArguments($argumentsClass))
        ->jsonSerialize();
}
echo json_encode(['tools' => $out]);
  1. POST /chatbot/v1/tools/{name} runs a tool. Deserialize the arguments with any deserializer; the bundles use symfony/serializer:
use Neika\Sdk\DTO\ToolCallContext;
use Neika\Sdk\DTO\Violation;
use Neika\Sdk\Exception\ArgumentsValidationException;
use Neika\Sdk\Exception\ToolNotFoundException;

$tool = $toolRegistry->get($name) ?? throw new ToolNotFoundException($name);
$body = json_decode($rawRequestBody, true);

$arguments = $serializer->denormalize($body['arguments'] ?? [], $tool->getArgumentsClass());

$violations = [];
foreach ($validator->validate($arguments) as $violation) {
    $violations[] = new Violation($violation->getPropertyPath(), (string) $violation->getMessage());
}
if ($violations !== []) {
    throw new ArgumentsValidationException($violations);
}

$context = new ToolCallContext(
    $body['context']['conversationId'],
    $body['context']['locale'],
    $body['context']['channelCode'] ?? null,
);
echo json_encode($tool->execute($arguments, $context)->jsonSerialize());
  1. GET /chatbot/v1/sources and GET /chatbot/v1/sources/{name} list and read the data sources:
use Neika\Sdk\DTO\SourceQuery;
use Neika\Sdk\Exception\SourceNotFoundException;

// list
$out = [];
foreach ($sourceRegistry->all() as $source) {
    $out[] = $source->getDefinition()->jsonSerialize();
}
echo json_encode(['sources' => $out]);

// read: ?locale=cs_CZ&channel=&cursor=&ids[]=
$source = $sourceRegistry->get($name) ?? throw new SourceNotFoundException($name);
$query = new SourceQuery(
    locale: $_GET['locale'] ?? '',
    channel: $_GET['channel'] ?? null,
    cursor: $_GET['cursor'] ?? null,
    ids: $_GET['ids'] ?? null,
);
echo json_encode($source->getDocuments($query)->jsonSerialize());

Have channels? Implement ChatbotLocaleContextInterface and match the requested locale against a channel's locales with LocaleMatcher.

HTTP contract

The paths are fixed. The backend calls /chatbot/v1/... on your host; you only set the origin (scheme and host) on the backend.

Method Path Request Response
GET /chatbot/v1/tools Accept-Language (optional) { "tools": [ {name, description, inputSchema, sensitiveArguments, ui?} ] }
POST /chatbot/v1/tools/{name} { "arguments": {...}, "context": { "conversationId", "locale", "channelCode"? } } { "content": [{type,text}], "blocks": [], "isError": bool, "privateValues"?: [{key,value}] }
GET /chatbot/v1/sources — { "sources": [ {name, description, locales} ] }
GET /chatbot/v1/sources/{name} ?locale=&channel=&cursor=&ids[]= { "documents": [...], "nextCursor": string|null }
  • ids[] takes up to 500 ids. With ids[], the cursor is ignored and nextCursor is null.
  • Check the auth header yourself. The backend sends Authorization: Bearer <apiSecret>. Compare it with hash_equals(), or let the Symfony bundle's firewall do it.
  • Every failure returns { "error": { "code", "message", "violations" } }. Throw a ChatbotApiException subclass: getErrorCode() gives the code, getStatusCode() the HTTP status.
Exception Code Status
ToolNotFoundException tool_not_found 404
SourceNotFoundException source_not_found 404
ArgumentsValidationException validation_failed, with violations 422
InvalidCursorException invalid_cursor 400
InvalidLocaleException invalid_locale 400

Push catalog changes

Tell the backend which catalog entries changed, and it re-indexes them. The client speaks PSR-18, so plug in any HTTP client (Guzzle, Symfony's Psr18Client) and PSR-17 factories:

use Neika\Sdk\DTO\CatalogChange;
use Neika\Sdk\DTO\SiteCredentials;
use Neika\Sdk\Enum\CatalogSourceName;
use Neika\Sdk\Ingest\CatalogIngestClient;

$client = new CatalogIngestClient(
    $psr18Client,     // Psr\Http\Client\ClientInterface
    $psr17Factory,    // Psr\Http\Message\RequestFactoryInterface
    $psr17Factory,    // Psr\Http\Message\StreamFactoryInterface
    'https://neika.ai',
);

$credentials = new SiteCredentials($siteKey, $ingestSecret);
$change = new CatalogChange(CatalogSourceName::Products, 'cs_CZ', ['CLIPPER-01', 'CLIPPER-02']);
$result = $client->send($credentials, $change);

if ($result->isThrottled()) {
    // nothing was queued: send the same ids again after $result->retryAfterSeconds
}
foreach ($result->jobs as $job) {
    // $job->externalId, $job->jobId, $job->status (CatalogJobStatus), $job->violation
}

Build SiteCredentials right before each call. The client doesn't keep them, so a re-paired secret works on the next call, in long-running workers too.

  • It sends POST {backend}/api/v1/catalog/changes with Authorization: Bearer {siteKey}.{ingestSecret}.
  • source is products, categories or cms_pages (CatalogSourceName).
  • Send 1 to 500 ids per call. Anything else throws InvalidArgumentException, so chunk them yourself.
  • 202 → accepted, with one job per id: queued, or rejected with a violation. The other ids are still queued.
  • jobId stays the same for every send of one id, per source and locale. Sending an id again is safe.
  • 429 (over 30 calls a second from your IP) or 503 (the backend can't queue right now) → isThrottled(), and nothing was queued. Wait retryAfterSeconds and send the same ids again. With no Retry-After header, it's 1 second.
  • Auth and validation failures throw CatalogIngestException (getStatusCode(), getBackendErrorCode()).

Chat click tracking

Links the chat sends to your site carry a neikaclid query parameter. Report the landing and the orders it leads to, and the dashboard shows chat link visits, orders and revenue. Auth and setup match the catalog client.

use Neika\Sdk\DTO\ChatOrder;
use Neika\Sdk\DTO\SiteCredentials;
use Neika\Sdk\Exception\TelemetryException;
use Neika\Sdk\Telemetry\ClickId;
use Neika\Sdk\Telemetry\TelemetryClient;

$telemetry = new TelemetryClient($psr18Client, $psr17Factory, $psr17Factory, 'https://neika.ai');
$credentials = new SiteCredentials($siteKey, $ingestSecret);

try {
    // landing page view: a real browser GET, after the response succeeded
    $clickId = (new ClickId())->find($_GET);
    if ($clickId !== null) {
        $session->set('neikaclid', $clickId);
        $telemetry->reportLinkVisit($credentials, $clickId, 'https://shop.example/product/clipper');
    }

    // order placed: report it only when the session kept a click id
    $orderClickId = $session->get('neikaclid');
    if ($orderClickId !== null) {
        $telemetry->reportOrder($credentials, new ChatOrder($orderClickId, $order->getNumber(), 123450, 'CZK'));
    }
} catch (TelemetryException $exception) {
    $logger->warning($exception->getMessage());   // never fail the page or the order over it
}
  • POST {backend}/api/v1/catalog/link-visits sends {clickId, pageUrl}. Send the page URL without query or fragment.
  • POST {backend}/api/v1/catalog/chat-orders sends {clickId, orderNumber, revenue, currency}.
  • revenue is in ISO 4217 minor units: CZK 1234.50 → 123450, JPY 500 → 500. currency is the ISO code.
  • Skip bots, link previews and prefetch requests (Sec-Purpose: prefetch).
  • 202 means done. Anything else, 429 included, throws TelemetryException (getStatusCode(), getBackendErrorCode()). Log it and move on.

Pairing

The shop admin clicks Connect in the Neika dashboard, and your shop gets its credentials. The browser lands on {your base URL}/chatbot/pair?code=…. Serve that path:

use Neika\Sdk\Exception\PairingException;
use Neika\Sdk\Pairing\HostMatcher;
use Neika\Sdk\Pairing\PairingClient;

$pairing = new PairingClient($psr18Client, $psr17Factory, $psr17Factory, 'https://neika.ai');

$apiSecret = bin2hex(random_bytes(32));
$ingestSecret = bin2hex(random_bytes(32));

try {
    $result = $pairing->complete($_GET['code'] ?? '', $apiSecret, $ingestSecret);
} catch (PairingException $exception) {
    http_response_code(400);
    echo htmlspecialchars($exception->getErrorCode());
    return;
}

$isOwnHost = (new HostMatcher())->isSameHost($result->baseUrl, 'shop.example');
if (!$isOwnHost) {
    http_response_code(400);
    return;
}

$store->save($result->siteKey, $apiSecret, $ingestSecret, $result->channelCodes);
header('Location: ' . $result->returnUrl, true, 302);
  • It sends POST {backend}/api/v1/tool-server-pairings/complete with {code, apiSecret, ingestSecret}. Send it to your configured https backend only, never to a URL from the query.
  • 200 → PairingResult: siteKey, baseUrl (the connection's address), channelCodes, returnUrl and verifiedDomains (the website's verified domains).
  • Compare the host of baseUrl with a host you configured, never with the request's Host header. HostMatcher::isSameHost() ignores case, a trailing dot and IDN spelling, but not www.. On a mismatch the code is someone else's: save nothing.
  • Serving the channels on several hosts? Check each host against verifiedDomains with HostMatcher::isCoveredByDomain($host, $domain). It accepts the domain and its subdomains, with www. stripped. If any host isn't covered, save nothing.
  • Secrets are 32 to 128 characters; bin2hex(random_bytes(32)) gives 64.
  • apiSecret is the bearer token the backend sends on every /chatbot/v1 call.
  • ingestSecret and siteKey make your SiteCredentials.
  • Redirect to returnUrl, and the dashboard tests the connection with the new secret.

The code works once and expires 10 minutes after the admin clicked Connect. A refused code is used up too, so the admin clicks Connect again.

Failures throw PairingException. getErrorCode() is the backend's error.code or one of these:

Code When
pairing_not_found The code is unknown, used or expired.
validation_failed The body is bad, e.g. a secret outside 32–128 characters.
rate_limited Too many unknown codes came from your IP. Wait 10 minutes.
transport_failed The backend is unreachable (PairingException::TRANSPORT_FAILED).
invalid_response A non-200 came without an error code, or a 200 is missing a field (PairingException::INVALID_RESPONSE).

Widget embed

use Neika\Sdk\Widget\WidgetSnippet;

echo (new WidgetSnippet())->render(
    'https://neika.ai',  // backend origin
    $siteKey,            // public site key
    $cdnUrl,             // optional; '' → https://neika.b-cdn.net/widget/v1/chat.js
    $locale,             // optional; '' → the browser detects it
    defer: true,         // optional; false → plain <script src="…"> without defer
);

It prints the loader <script> and the <ai-chat-widget> element, with every attribute value escaped.

Tests

composer install
vendor/bin/phpunit

License

MIT, see LICENSE.