fluffydiscord / honkers-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 auto-updated.
Last update: 2026-09-22 14:11:47 UTC
README
Framework-agnostic PHP for the honkers.dev chatbot. Serve the tool/source endpoints the chatbot calls, push catalog changes back to the backend, and render the chat widget — no framework required.
Used by:
fluffydiscord/symfony-honkers-bundle— Symfony wiring + the/chatbot/v1HTTP endpoints.fluffydiscord/sylius-honkers-bundle— Sylius defaults (tools, data sources, shop widget).
Examples
A tool — one class, one arguments DTO:
use FluffyDiscord\Honkers\Contract\ChatbotToolInterface; use FluffyDiscord\Honkers\DTO\ContentItem; use FluffyDiscord\Honkers\DTO\ToolCallContext; use FluffyDiscord\Honkers\DTO\ToolDefinition; use FluffyDiscord\Honkers\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)]); } }
Argument DTOs carry symfony/validator constraints; ArgumentsSchemaGenerator turns them into a
JSON Schema (with runtime-loaded choice enums):
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]
Registries wrap a plain iterable of tools/sources and key them by getDefinition()->name:
$tool = $toolRegistry->get('greet'); // null when unknown foreach ($toolRegistry->all() as $tool) { /* ... */ }
Names must be unique — on a duplicate, get() returns the first match. The Symfony bundle fails
the container build on a duplicate; standalone, keep them distinct yourself.
Also here: ChatbotDataSourceInterface (bulk documents), ToolChoiceLoaderInterface +
#[ToolChoice] (DB-backed enums), the ChatbotLocaleContextInterface port the host app implements,
result DTOs (ToolResult, ToolDefinition, SourceDocument, …) and helpers (CursorCodec,
LocaleMatcher, HtmlToText).
Standalone setup (no framework)
The SDK owns the logic, not the transport. You wire the registries once, then map four HTTP routes to them.
Wire the pieces — each registry takes a plain list of services, no container:
use FluffyDiscord\Honkers\Registry\ToolRegistry; use FluffyDiscord\Honkers\Registry\DataSourceRegistry; use FluffyDiscord\Honkers\Registry\ToolChoiceLoaderRegistry; use FluffyDiscord\Honkers\Schema\ArgumentsSchemaGenerator; use Symfony\Component\Validator\Validation; $tools = [new GreetTool()]; // ChatbotToolInterface, keyed at runtime by getDefinition()->name $sources = []; // ChatbotDataSourceInterface $loaders = []; // ToolChoiceLoaderInterface, matched by class $toolRegistry = new ToolRegistry($tools); $sourceRegistry = new DataSourceRegistry($sources); $schema = new ArgumentsSchemaGenerator(new ToolChoiceLoaderRegistry($loaders)); $validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
GET /chatbot/v1/tools — tool list with input schemas:
$out = []; foreach ($toolRegistry->all() as $tool) { $out[] = $tool->getDefinition() ->withInputSchema($schema->generate($tool->getArgumentsClass())) ->jsonSerialize(); } echo json_encode(['tools' => $out]);
POST /chatbot/v1/tools/{name} — body { "arguments": {...}, "context": {...} }:
use FluffyDiscord\Honkers\DTO\ToolCallContext; use FluffyDiscord\Honkers\DTO\Violation; use FluffyDiscord\Honkers\Exception\ToolNotFoundException; use FluffyDiscord\Honkers\Exception\ArgumentsValidationException; $tool = $toolRegistry->get($name) ?? throw new ToolNotFoundException($name); $body = json_decode($rawRequestBody, true); // Turn the arguments array into the typed DTO. Any deserializer works; // symfony/serializer is what the bundles use (not an SDK dependency). $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); // -> 422 envelope } $context = new ToolCallContext( $body['context']['conversationId'], // must be a UUID $body['context']['locale'], // an ICU locale, e.g. cs_CZ $body['context']['channelCode'] ?? null, ); echo json_encode($tool->execute($arguments, $context)->jsonSerialize());
GET /chatbot/v1/sources and GET /chatbot/v1/sources/{name}:
use FluffyDiscord\Honkers\DTO\SourceQuery; use FluffyDiscord\Honkers\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());
You provide, around the SDK: auth, routing, and argument deserialization. Locale
matching against a channel's served locales is optional — use LocaleMatcher and implement
ChatbotLocaleContextInterface if you have channels.
HTTP contract (what the chatbot backend expects)
The paths are fixed: the backend calls /chatbot/v1/... on your host. Serve the endpoints at exactly
these paths — only the origin (scheme + host) is yours to configure on the backend.
| Method | Path | Request | Response |
|---|---|---|---|
| GET | /chatbot/v1/tools |
Accept-Language (optional) |
{ "tools": [ {name, description, inputSchema, ui?} ] } |
| POST | /chatbot/v1/tools/{name} |
{ "arguments": {...}, "context": { "conversationId", "locale", "channelCode"? } } |
{ "content": [{type,text}], "blocks": [], "isError": bool } |
| GET | /chatbot/v1/sources |
— | { "sources": [ {name, description, locales} ] } |
| GET | /chatbot/v1/sources/{name} |
?locale=&channel=&cursor=&ids[]= (ids[] max 500; then cursor ignored, nextCursor null) |
{ "documents": [...], "nextCursor": string|null } |
- Auth — the backend sends
Authorization: Bearer <shared-secret>. The SDK does no auth; verify the header yourself (hash_equals) or let the Symfony bundle's firewall do it. - Errors — every failure returns
{ "error": { "code", "message", "violations" } }. Throw aChatbotApiExceptionsubclass;getErrorCode()gives thecode,getStatusCode()the HTTP status (tool_not_found/source_not_found404,validation_failed422 withviolations,invalid_cursor/invalid_locale400).
Outbound: push catalog changes
Tell the backend which catalog entries changed so it re-indexes them. This is the only call your
server makes to honkers.dev — POST {backend}/api/v1/catalog/changes, auth Bearer {siteKey}.{ingestSecret}.
The client speaks PSR-18, so plug in any HTTP client (Guzzle, Symfony's Psr18Client, …) and PSR-17
factories:
use FluffyDiscord\Honkers\DTO\CatalogChange; use FluffyDiscord\Honkers\Enum\CatalogSourceName; use FluffyDiscord\Honkers\Ingest\CatalogIngestClient; $client = new CatalogIngestClient( $psr18Client, // Psr\Http\Client\ClientInterface $psr17Factory, // Psr\Http\Message\RequestFactoryInterface $psr17Factory, // Psr\Http\Message\StreamFactoryInterface 'https://honkers.dev', $ingestSecret, ); $change = new CatalogChange(CatalogSourceName::Products, 'cs_CZ', ['CLIPPER-01', 'CLIPPER-02']); $result = $client->send($siteKey, $change); if ($result->isThrottled()) { // backend is busy — retry after $result->retryAfterSeconds } foreach ($result->jobs as $job) { // $job->externalId, $job->jobId, $job->status (CatalogJobStatus), $job->violation }
sourceisproducts,categories, orcms_pages(CatalogSourceName).- Max 500 ids per call. More than that throws — chunk them yourself.
202→accepted, with a per-id job list (bad ids come backrejectedwith aviolation).429→isThrottled(),retryAfterSecondsset; nothing was queued.- Auth/validation failures throw
CatalogIngestException(getStatusCode(),getBackendErrorCode()).
Widget embed
Render the chat widget markup for any page:
use FluffyDiscord\Honkers\Widget\WidgetSnippet; echo (new WidgetSnippet())->render( 'https://honkers.dev', // backend origin $siteKey, // public site key $cdnUrl, // optional; '' → {backend}/widget/v1/chat.js $locale, // optional; '' → the browser detects it );
Emits a deferred loader <script> and the <ai-chat-widget> element; all attribute values are escaped.
Tests
composer install vendor/bin/phpunit