Kemp.rest / Examples / Code / llms.txt
KEMP.REST / EXAMPLES / CODE
llms.txt on the live site
Written by Kevin Kemp in PHP and Laravel for AnswerPins. The file is built from published pages and served by a WordPress plugin.
View the codePublishing a service-area page rebuilds llms.txt and sends it to the owner’s WordPress. The file tells an answer engine who the business is and which pages are already live. The llms.txt generator on presscare.com is the same kind of file, filled in by hand in the browser.
Type of code
A Laravel renderer, a sync service, two controller actions, and a small WordPress plugin. The plugin is what makes https://the-site/llms.txt answer.
Technology
PHP, Laravel, the WordPress REST API at answerpins/v1/llms, and a WordPress plugin that stores the text in an option. The file is markdown: an H1, a blockquote summary, then links. Application passwords stay on the business record.
What it does
- The H1 is the business name. The summary is the description, held to one line, or a sentence built from the services and towns. Address, phone, and website follow.
- Only pages with status published become links. The title and the intro are the link text and the note. Services are listed under their own heading.
- A marker comment,
<!-- answerpins -->, is the last line, so a later update can tell this file from one someone else uploaded. - The plugin answers
/llms.txtoninit, as plain text, with no cache. A POST from AnswerPins replaces the option. The route requiresedit_pages. An empty file or one over 20,000 characters is rejected. - After WordPress accepts the text, AnswerPins opens the public URL and compares the body. An older AnswerPins file, or a different file already on the server, is reported so the owner can remove it from the same folder as
robots.txt. A host-level file at that address is served before WordPress runs. - If the plugin is missing, the page that just published stays published. The pages screen shows the reason.
Problem it solved
Answer engines needed a current map of the pages the business had actually published, at the root of the site, updated when a new page went live.
Code
Back to summary · AnswerPins · llms.txt
// —— app/Services/LlmsTxt.php ——
<?php
namespace App\Services;
use App\Models\Business;
use Illuminate\Support\Str;
/**
* The llms.txt file for a business: who they are, and the pages already published.
* A short pointer for answer engines. The long llms-full.txt dump is not produced.
*/
class LlmsTxt
{
/** Marks a file written by AnswerPins, so a later update can tell it from someone else's. */
public const MARKER = '<!-- answerpins -->';
public function render(Business $business): string
{
$business->unsetRelation('pages');
$business->load(['services', 'towns', 'facts', 'pages']);
$name = trim((string) ($business->name ?: $business->domain ?: 'Business'));
$services = $business->services->pluck('name')->map(fn ($n) => trim((string) $n))->filter()->values();
$towns = $business->towns->pluck('name')->map(fn ($n) => trim((string) $n))->filter()->values();
$summary = $this->oneLine((string) $business->description);
if ($summary === '') {
$summary = $name.($services->isNotEmpty() ? ' offers '.$this->join($services->all()) : '')
.($towns->isNotEmpty() ? ' in '.$this->join($towns->all()) : '').'.';
}
$lines = ['# '.$name, '', '> '.$summary, ''];
if ($business->address) {
$lines[] = $this->oneLine($business->address);
}
if ($phone = $business->fact('phone')) {
$lines[] = 'Phone: '.$this->oneLine($phone);
}
if ($business->domain) {
$lines[] = 'Website: https://'.$business->domain.'/';
}
$lines[] = '';
$links = [];
foreach ($business->pages->where('status', 'published')->sortBy('id') as $page) {
$url = $page->wp_url ?: ($business->domain && $page->slug ? 'https://'.$business->domain.'/'.ltrim($page->slug, '/') : null);
if (! $url) {
continue;
}
$title = $this->linkText($this->oneLine($page->title ?: $url));
$note = $this->oneLine((string) $page->intro);
$links[] = '- ['.$title.']('.$url.')'.($note !== '' ? ': '.$note : '');
}
if ($links) {
$lines[] = '## Pages';
$lines[] = '';
array_push($lines, ...$links);
$lines[] = '';
}
if ($services->isNotEmpty()) {
$lines[] = '## Services';
$lines[] = '';
foreach ($services as $service) {
$lines[] = '- '.$service;
}
$lines[] = '';
}
$lines[] = self::MARKER;
return rtrim(implode("\n", $lines))."\n";
}
private function oneLine(string $text): string
{
$text = trim(preg_replace('/\s+/u', ' ', strip_tags($text)) ?? '');
return Str::limit($text, 300, '');
}
private function linkText(string $text): string
{
return str_replace(['[', ']'], ['(', ')'], $text);
}
/** @param list<string> $items */
private function join(array $items): string
{
if (count($items) < 2) {
return $items[0] ?? '';
}
$last = array_pop($items);
return implode(', ', $items).' and '.$last;
}
}
// —— app/Services/LlmsSync.php ——
<?php
namespace App\Services;
use App\Models\Business;
use RuntimeException;
/** Sends the current llms.txt to a connected WordPress site and records whether it landed. */
class LlmsSync
{
public function __construct(private LlmsTxt $txt, private WordPress $wp) {}
public function push(Business $business): void
{
if (! $business->wordpressConnected()) {
throw new RuntimeException('Connect WordPress first.');
}
$text = $this->txt->render($business);
try {
$this->wp->putLlms($business, $text);
$this->wp->confirmLlms($business, $text);
} catch (\Throwable $e) {
$business->update(['llms_error' => mb_substr($e->getMessage(), 0, 500)]);
throw $e;
}
$business->update(['llms_synced_at' => now(), 'llms_error' => null]);
}
}
// —— app/Services/WordPress.php — send llms.txt and confirm the public file ——
/** Replaces the llms.txt the AnswerPins plugin serves at the site root. */
public function putLlms(Business $business, string $text): void
{
$res = $this->api($business)->post($this->route($business->wp_api_url, 'answerpins/v1/llms'), ['text' => $text]);
if ($res->status() === 404) {
throw new RuntimeException('The AnswerPins plugin is not installed on the site yet. Download the plugin, install it in WordPress, then send the file again.');
}
$this->ok($res, 'update llms.txt');
}
/** The public address of llms.txt, taken from the connected site. */
public function llmsUrl(Business $business): string
{
$root = preg_replace('#wp-json/.*$#', '', $business->wp_api_url);
$root = preg_replace('#\?rest_route=.*$#', '', (string) $root);
return rtrim((string) $root, '/').'/llms.txt';
}
/**
* Opens the public file after a send. An exact match is ours and current.
* Our marker on different words is an older AnswerPins file sitting on the server.
* Any other body is a file we did not write.
*/
public function confirmLlms(Business $business, string $text): void
{
$url = $this->llmsUrl($business);
try {
$res = Http::timeout(20)->withOptions(['allow_redirects' => ['max' => 3]])->get($url);
} catch (\Throwable) {
throw new RuntimeException("WordPress accepted llms.txt, but {$url} could not be opened. Send it again.");
}
$body = trim(str_replace("\r\n", "\n", $res->body()));
$want = trim(str_replace("\r\n", "\n", $text));
if ($res->status() === 200 && $body === $want) {
return;
}
if (str_contains($body, LlmsTxt::MARKER)) {
throw new RuntimeException("An older AnswerPins llms.txt is already on the site, so {$url} is not serving this update. Remove that file from the same folder as robots.txt, then send again.");
}
if ($res->status() === 200) {
throw new RuntimeException("A different llms.txt is already on the site, so {$url} is not serving the AnswerPins file. Remove it from the same folder as robots.txt, then send again.");
}
throw new RuntimeException("WordPress accepted llms.txt, but {$url} did not return it. Install the AnswerPins plugin, then send again.");
}
// —— wordpress/answerpins-llms/answerpins-llms.php ——
<?php
/**
* Plugin Name: AnswerPins
* Description: Serves llms.txt so AI answer engines can read the pages AnswerPins publishes. AnswerPins updates the file when a page is published.
* Version: 1.0.0
* Author: AnswerPins
*/
defined('ABSPATH') || exit;
add_action('init', 'answerpins_llms_maybe_serve', 0);
/**
* Answers https://the-site/llms.txt from the text AnswerPins last sent.
* A real file at that address, if one exists, is served by the host before WordPress runs.
*/
function answerpins_llms_maybe_serve(): void
{
$path = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
if (! is_string($path) || ! preg_match('#/llms\.txt$#', $path)) {
return;
}
$text = (string) get_option('answerpins_llms_txt', '');
status_header($text === '' ? 404 : 200);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-cache');
echo $text === '' ? "llms.txt is not on this site yet.\n" : $text;
exit;
}
add_action('rest_api_init', function (): void {
register_rest_route('answerpins/v1', '/llms', [
'methods' => 'POST',
'permission_callback' => function (): bool {
return current_user_can('edit_pages');
},
'callback' => function (WP_REST_Request $request) {
$text = (string) $request->get_param('text');
if (trim($text) === '' || strlen($text) > 20000) {
return new WP_Error('answerpins_llms', 'The file is empty or too long.', ['status' => 422]);
}
update_option('answerpins_llms_txt', $text, false);
return ['ok' => true];
},
]);
});
// —— app/Http/Controllers/PagesController.php — download and send ——
public function llms(Request $request, LlmsTxt $txt): Response
{
$business = $this->business($request);
return response($txt->render($business), 200, [
'Content-Type' => 'text/plain; charset=utf-8',
'Content-Disposition' => 'attachment; filename="llms.txt"',
]);
}
public function sendLlms(Request $request, LlmsSync $sync): RedirectResponse
{
$business = $this->business($request);
abort_unless($business->wordpressConnected(), 422, 'Connect WordPress first.');
try {
$sync->push($business);
} catch (\Throwable $e) {
return back()->withErrors(['llms' => $e->getMessage()]);
}
return back()->with('status', 'llms.txt is on your site.');
}