neika-ai / sdk
Framework-agnostic chatbot tool-server core: tools, data sources, JSON-schema generation and result DTOs
Requires
- php: ^8.1
- ext-intl: *
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- symfony/intl: ^6.4 || ^7.0 || ^8.0
- symfony/validator: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- nyholm/psr7: ^1.8
- phpunit/phpunit: ^10.0 || ^11.0 || ^12.0 || ^13.0
- symfony/translation-contracts: ^3.0
Suggests
- symfony/translation-contracts: ^3.0 to translate tool/source labels via the DTO translated() methods
Provides
None
Conflicts
None
Replaces
None
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
- Tools and data sources — one class each; the JSON Schema comes from your validator constraints
- Personal data in tools — a model hosted outside the EU reads placeholders; the customer sees real values
- Standalone setup — the four
/chatbot/v1endpoints in plain PHP - Push catalog changes — the chatbot re-indexes just the entries you changed
- Chat click tracking — chat link visits, orders and revenue in the dashboard
- Pairing — the shop admin clicks Connect; nobody copies a secret
- Widget embed — one call prints the chat widget
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.
- 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 throwsArgumentCountError.
GET /chatbot/v1/toolslists 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]);
POST /chatbot/v1/tools/{name}runs a tool. Deserialize the arguments with any deserializer; the bundles usesymfony/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());
GET /chatbot/v1/sourcesandGET /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. Withids[], the cursor is ignored andnextCursorisnull.- Check the auth header yourself. The backend sends
Authorization: Bearer <apiSecret>. Compare it withhash_equals(), or let the Symfony bundle's firewall do it. - Every failure returns
{ "error": { "code", "message", "violations" } }. Throw aChatbotApiExceptionsubclass:getErrorCode()gives thecode,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/changeswithAuthorization: Bearer {siteKey}.{ingestSecret}. sourceisproducts,categoriesorcms_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, orrejectedwith aviolation. The other ids are still queued.jobIdstays 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) or503(the backend can't queue right now) →isThrottled(), and nothing was queued. WaitretryAfterSecondsand send the same ids again. With noRetry-Afterheader, 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-visitssends{clickId, pageUrl}. Send the page URL without query or fragment.POST {backend}/api/v1/catalog/chat-orderssends{clickId, orderNumber, revenue, currency}.revenueis in ISO 4217 minor units: CZK 1234.50 →123450, JPY 500 →500.currencyis the ISO code.- Skip bots, link previews and prefetch requests (
Sec-Purpose: prefetch). 202means done. Anything else,429included, throwsTelemetryException(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/completewith{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,returnUrlandverifiedDomains(the website's verified domains).- Compare the host of
baseUrlwith a host you configured, never with the request'sHostheader.HostMatcher::isSameHost()ignores case, a trailing dot and IDN spelling, but notwww.. On a mismatch the code is someone else's: save nothing. - Serving the channels on several hosts? Check each host against
verifiedDomainswithHostMatcher::isCoveredByDomain($host, $domain). It accepts the domain and its subdomains, withwww.stripped. If any host isn't covered, save nothing. - Secrets are 32 to 128 characters;
bin2hex(random_bytes(32))gives 64. apiSecretis the bearer token the backend sends on every/chatbot/v1call.ingestSecretandsiteKeymake yourSiteCredentials.- 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.