# Raiju CMS - AI Agent Guide Raiju turns any HTML or PHP page into an editable website — no database required. Take a hand-crafted or AI-generated (vibe-coded) page, replace hardcoded text, images, and links with Raiju's PHP helper functions, and you get a fully functional CMS with inline editing. Content is stored as JSON files, the editable elements are marked with `data-*` attributes that the inline editor recognizes, and visitors see clean HTML with no editor markup. This guide covers everything you need to make pages editable. This is the canonical, tool-neutral guide for AI coding agents. `AGENTS.md` and `CLAUDE.md` are intentionally short entry points for tools that auto-load those filenames; keep detailed implementation guidance here to avoid contradictory instructions. ## Quick Reference | What | Where | |---|---| | Page templates | `{lang}/page-slug.php` (e.g. `en/about.php`) | | Content JSON | `content/pages/{lang}_{slug}.json` (e.g. `content/pages/en_about.json`) | | News posts | `content/news/{slug}.json` | | Footer content | `content/pages/footer.json` | | Form definitions | `content/forms/*.json` | | Site settings | `content/settings.json` | | Maintenance/private access | `includes/access-guard.php` + `content/settings.json` / page `visibility` | | Menu registry | `content/menus.json` | | Navigation | `includes/nav-config.php` | | Menu helpers | `includes/menu-helpers.php` | | Migration redirects | `content/redirects.json` (copy from `content/redirects.example.json`) | | Redirect helper | `includes/redirect-helper.php` | | Site page hook | `includes/site-page-hook.php` (optional, survives core updates) | | Block types | `includes/block-types.php` | | Template API | `includes/content-loader.php` | | Block renderers | `includes/block-renderers/{type}.php` | | Base stylesheet | `css/style.css` (custom properties at top) | | Component styles | `css/components.css` | | Page-specific styles | `css/page-{slug}.css` (generated by converter) | | Example templates | `examples/templates/` | | Example content | `examples/content/` | | HTML converter | `cli/convert.php` | | Page scaffolding | `cli/make.php` | | Events | `content/events.json` | | Email config | `content/settings.json` → `email` section | | SMTP mailer | `api/SmtpMailer.php` | | JSON form renderer | `includes/forms.php` | | Public form submit API | `api/form-submit.php` | | Legacy contact form API | `api/contact.php` | | AI gateway | `includes/ai/ai-helper.php` | | AI settings | `content/ai-settings.json` (no keys), `content/ai-secrets.json` (API keys only, chat and image services) | | AI image services | `includes/ai/image-services.json` (registry), `includes/ai/image-services.php` (adapters) | | AI usage/audit | `content/ai-usage.json`, `content/ai-audit/*.jsonl` | | AI image history | `content/ai-image-history.json` | | Deploy script | `deploy.example.sh` (copy to `deploy.sh` and configure) | | Security self-check | `includes/security-check.php` (Dashboard → System status → Security) | | Backup CLI | `cli/backup.php` (cron-friendly site backup runner) | | Backup helper | `includes/backup-helper.php` | ## Development Server ```bash php -S localhost:3000 router.php ``` For large image uploads raise the CLI limits (the default `upload_max_filesize` is often 2M): ```bash php -d upload_max_filesize=20M -d post_max_size=25M -S localhost:3000 router.php ``` The `router.php` handles clean URLs, language routing, and news post URLs without requiring Apache. For routing changes, login destinations, new handler directories and uploads, follow [Routing and deployment verification](ROUTING.md). The development server does not execute `.htaccess` or Apache's directory handling. Test both slash variants and the actual login redirect chain under Apache before claiming production routing is verified. In particular, `admin/dashboard.php` shares its name with the `admin/dashboard/` fragment directory; keep the explicit PHP login destination and compatibility aliases described in that guide. After uploading a site, open Dashboard → System status → Security and report its result. An nginx proxy that serves static files itself bypasses `.htaccess` and can expose `content/` (password hashes, SMTP password, form submissions) and `backups/`. The check detects this with short-lived probe files and shows the nginx rules; see [Hosting proxies and internal folders](ROUTING.md#hosting-proxies-and-internal-folders). Test existing files only: nginx passes missing files to Apache, which answers 403. ## JSON-Backed Public Forms Raiju supports simple, flat-file public forms stored in `content/forms/*.json`. This is the preferred path for vibe-coded sites: an agent can generate the form structure once, and non-technical admins can later adjust labels, placeholders, required state, select/radio options, column widths, success text, and notification behaviour in **Dashboard -> Settings -> Forms**. The goal is not a full visual form builder. Keep complex conditional logic, custom validation, and page-specific behaviour in the site template or a site-owned script. Use JSON forms when users need safe edits to an existing form. ### Form JSON ```json { "id": "contact", "label": "Kontaktformular", "description": "", "enabled": true, "submit": { "store": true, "email": true, "subject": "Kontaktanfrage: {name}", "successText": "Vielen Dank für deine Nachricht." }, "fields": [ { "key": "name", "type": "text", "label": "Name", "required": true, "width": 6 }, { "key": "email", "type": "email", "label": "E-Mail", "required": true, "width": 6 }, { "key": "message", "type": "textarea", "label": "Nachricht", "required": true, "width": 12 } ] } ``` Supported field types are `text`, `email`, `tel`, `textarea`, `select`, `radio`, `checkbox`, `date`, `time`, `hidden`, `heading`, and `note`. Supported widths are `3`, `4`, `6`, `8`, and `12` on a 12-column grid. `select` and `radio` fields use `options` entries shaped as `{ "value": "...", "label": "..." }`. ### Rendering For a direct server-side render: ```php $basePath, 'lang' => $currentLang]); ?> ``` For lazy loading, use the existing placeholder. `api/form.php?form=` renders JSON forms when a matching `content/forms/.json` exists: ```php $basePath, 'params' => ['lang' => $currentLang, 'basePath' => $basePath], ]); ?> ``` JSON forms submit to `api/form-submit.php`. The renderer adds the one-time token, honeypot, `form_id`, loading spans, and `.form-feedback` markup expected by the shared footer JavaScript. Submissions are stored in `content/mails.json` with `formId`, `formLabel`, and a structured `fields[]` array; the dashboard inbox can filter by form. ### Legacy and Custom PHP Forms The legacy `includes/contact-form.php` and `api/contact.php` continue to work for older templates. For fully custom PHP forms that do not use JSON rendering, include `includes/form-protection.php`, call `raijuFormProtectionFields($id)`, validate with `raijuValidateFormToken()`, and store messages with the same `formId`/`formLabel` metadata if they should appear clearly in the inbox. ## AI Gateway Raiju core AI features must use `includes/ai/ai-helper.php` instead of calling LLM or image-generation vendors directly. The gateway stores provider settings in `content/ai-settings.json`, supports OpenAI-compatible providers, keeps API keys server-side in `content/ai-secrets.json` (never in the settings file, which is safe to commit), enforces feature flags, daily/monthly limits, token caps, rough cost estimates, and writes audit events to `content/ai-audit/*.jsonl`. Use the existing admin API actions (`ai-chat`, `ai-generate-text`, `ai-generate-image`, `ai-image-history`, `clear-ai-image-history`, `load-ai-settings`, `save-ai-settings`, `ai-test`) for dashboard features. API keys are never exposed by `load-ai-settings`; it only returns `hasApiKey` and provider metadata. Do not add direct browser-to-provider calls. Provider credentials are stored per provider, so admins can switch between OpenAI, OpenRouter, Anthropic (Claude), Ollama, LM Studio, or another OpenAI-compatible endpoint without replacing keys. The Anthropic provider is translated to and from the Messages API inside the gateway (including streaming) so all callers keep one OpenAI-style request/response shape; it serves chat and text only; images then come from the separate image service. Chat replies can stream over Server-Sent Events with an automatic fallback to the buffered JSON response. Local/private URLs (`localhost`, private IP ranges, or self-hosted gateways) must only be accepted when the explicit local-provider setting is enabled. Images come from an **image service** that is chosen apart from the chat provider (`imageService` in `content/ai-settings.json`, section "Images" in the AI settings). Its default `inherit` uses the chat provider's own image models (not available with Anthropic); the other choices are OpenAI, OpenRouter, Kie.ai, fal.ai, Comet API and `custom` (any OpenAI-compatible `/images/generations` endpoint with base URL, model id, size mode `size`|`aspect_ratio`|`none`, authentication `bearer`|`x-api-key` and optional extra parameters). Resolve it with `raijuAiImageServiceResolve($settings)`; never read `imageModel` for the effective model, use `raijuAiEffectiveImageModel()`, and ask `raijuAiImageServiceUsable()` / `raijuAiImageUnavailableReason()` whether images can be made. Each service has its own key in `content/ai-secrets.json` (`imageServices..apiKey`); OpenAI, OpenRouter and Kie.ai fall back to the chat key of the same provider, only when the chat provider's base URL has the service's host (`raijuAiImageChatKey()`), so a key never goes to a host it was not made for. Remove one with `ai-clear-secret` (`image:`). Adapters live in `includes/ai/image-services.php` and share one input (`prompt`, `count`, `aspectRatio`, `size`, `imageScale`, `outputFormat`, `referencePaths`, ...) that `raijuAiGenerateImageExecute()` builds; they return the stored image paths. Existing adapters: `openai-images`, `openrouter-chat`, `kie-task`, `fal-queue` (submit, poll `status_url`, fetch `response_url`; tasks sit in the request ledger so a stopped job resumes) and `gemini-generate` (Comet's `generateContent` route). To add a **service**, add an entry to `includes/ai/image-services.json` (adapter, base URL, `keyFrom`, models) and, for a new protocol, an adapter function plus its case in `raijuAiImageAdapterRun()`. To add a **model**, add it to the service's `models` with what it accepts (`aspects`, `aspectParam` or `sizeParam` with `sizePresets`, `countParam`/`maxCount`, `formatParam`/`formats`, `resolutionParam`/`resolutions`, `references`, `price`). Set `verified` to the date you checked the parameters against the provider's official documentation, or `false` when you did not; never guess a parameter or a price. A model that is not listed uses the `defaults` of its service. Result images are fetched with `raijuNetSafeFetch()` (`raijuAiImageFetchBinary()`), addresses a provider returns for polling are followed only on the service's own host, keys never enter audit entries, jobs, history or error messages, and `raijuAiProviderErrorMessage()` is the one place that reads provider error bodies. The registry entry of a model may carry its own `adapter` and a `basePath` below the service address (Comet: Nano Banana over `gemini-generate`, GPT Image 2.5 over `openai-images` below `/v1` with an `openai.params` list of what the route takes, Seedream over `comet-task`), and for Kie.ai a `kie` block with the task id and the separate image-to-image id, `resolutionMap` (Kie's Seedream takes `quality` basic/high for 1K/2K), `formatAliases`, `aspectAuto` and `references.param`. `raijuAiKieTaskInput()` builds a Kie task from it; `raijuAiImageTaskRun()` is the queue runner shared by fal.ai and Comet's asynchronous route (submit what the ledger does not know, poll, download, clean the ledger). Reference images: the agent's `generate_image` takes `references` (a `mediaRef` of the run or a `libraryPath`); `raijuAgentGenerateReferences()` checks them when the tool is called, and `raijuAiImageServiceSupportsReferences()` says whether the service can use them (OpenAI-style `/images/edits`, OpenRouter and Kie as before, fal.ai only for models with an `editModel`, Comet by `inline_data`). Results are labelled `modified`, not `generated`. Live model lists: `raijuAiImageLiveModels()` (fal.ai `GET /v1/models?category=text-to-image&status=active`, Comet `GET /api/models`, entries of `model_type` image) feeds the model suggestions through the admin action `ai-image-models`; add a service with a documented list endpoint to `RAIJU_AI_IMAGE_LIVE_SERVICES` with a parser. Tests redirect the list hosts with `RAIJU_AGENT_TEST_NET.imageListBases`. Prices per image: `raijuAiImagePrice()` takes the admin's price for the model (`pricing.imageModels["|"]`), then the registry's, then the default price per image (`pricing.imageCentsPerRequest`), otherwise the cost is unknown (reservation 0, `priced:false`). An entry is `{perImage?, currency, byResolution?: {1K, 2K, 4K}, byQuality?: {low, medium, high}}`; a request is priced by its resolution step (`raijuAiImageRequestResolution()`), then its quality, then `perImage`. Generated images are validated and saved under `assets/images/generated/`, are visible in the Media Library, and are indexed in `content/ai-image-history.json` with prompt, model, options, response status, and local media paths. OpenAI image edits use multipart image uploads; OpenRouter-compatible image generation uses the configured model id and may have provider-specific option support. Never automatically retry an ambiguous paid POST, even when no response bytes arrived. Resume known provider task IDs through the request ledger; outcomes without a task ID need reconciliation before another submission. Limits are guides, not hard walls (`raijuAiCheckLimits()`): a request that starts below a limit may finish, and requests of an agent run under way (`agent_run_*_N` with N > 1, `agent_read_*`) may use up to 10 % more of the daily request limits and the monthly budget. The first request of a run, standalone requests and image requests stay strict. `raijuAiUsageSummary()['warnings']` lists limits at 80 % or reached; the dashboard, the AI settings and the assistant show them. OpenAI-compatible requests go through `raijuAiCompatRequest()`, which sends `max_completion_tokens` to api.openai.com and learns per model (content/ai-model-quirks.json) when a provider rejects `max_tokens`, `max_completion_tokens` or `temperature`. Agent turns with tools (`raijuAiChatWithTools()`) use the Responses API (`/responses`, `store: false`, encrypted reasoning items carried in the assistant message as `reasoningItems`) for api.openai.com and for models a host refused on chat/completions with "use /v1/responses"; everything else stays on chat/completions. Every gateway call serves a purpose (`purpose` option: `chat` for assistant, agent and full drafts, `text` for field suggestions, rewrites and SEO; `raijuAiGenerateText()` defaults to `text`). The purpose and the model decide the reasoning effort through the registry `includes/ai/model-capabilities.json` (`raijuAiReasoningPlan()`); never add a reasoning parameter for a model that is not listed there, and never send `temperature` to a model that the registry marks `omitTemperature`. Costs come from `includes/ai/pricing.php`: use `raijuAiCostForUsage()` with the model that was really used, never a global price; an unknown price is `priced: false`, not 0. Amounts are cents with four decimals (`raijuAiAddCents()` to sum). The dashboard assistant (`mode=dashboard` on `ai-copilot-chat`) runs one tool turn with `open_section`, `show_location` and `change_setting` (`includes/ai/dashboard-actions.php`). Calls are validated per role and returned as `actions` cards; nothing is changed by the chat. The card's button calls `window.RaijuDashboardActions.run()` (admin/dashboard/scripts/settings.php), which fills the existing theme or language form and submits it. To allow another setting, add it to `RAIJU_DASHBOARD_SETTINGS` and `raijuMapActionEntries()`, validate it in `raijuDashboardActionFromCall()` (accept the words people and models use, like `raijuDashboardResolveLanguage()`), add its texts, and handle it in the runner. Whatever the assistant cannot or may not do must end in a **link card** (`type: link`, a same-site path that opens in a new tab with `rel="noopener"`), never in "see the settings": `raijuDashboardFinishTurn()` adds cards found from the person's own words when the model offered none (no tool calling, an unusable value, an editor asking for an administrator setting). The dashboard system prompt always carries the map (`raijuDashboardKnowledgePrompt()`), with or without tools. #### Raiju map (`includes/ai/raiju-map.php`) The one place that knows where things are in the dashboard: areas (`raijuMapSections()`), settings tabs (`raijuMapSettingsTabs()`) and the things people look for (`raijuMapEntries()`: id => area, tab, label key, synonyms, English hint). Names are label keys of `admin/lang/*.json`, so breadcrumbs ("Settings › Language › Admin Language") exist in all admin languages; free text is matched against the names in every language plus the synonyms (`raijuMapLookup()`). Links are built from the script path of the request (`raijuMapDashboardUrl()`) with the dashboard's own hashes (`#settings/language`). Roles and switched-off modules are respected: an editor gets the place without a link, a switched-off module points to Settings › Modules. **When you add a dashboard area, a settings tab or a setting people ask about, add the entry here**; `tests/raiju-map-smoke.php` compares the map with `admin/dashboard.php`, requires every name in all nine languages and rejects one synonym on two entries. The package preview (`?rj_agent_preview={id}&rj_agent_return={local path}`) opens in the same tab. Its bar only holds links back to the return path: `#rj-agent-open` and `#rj-agent-apply={id}`. `js/ai-agent.js` reads that hash once the open package is loaded and opens the panel or its own apply confirmation. Never add cross-tab messaging or CSRF tokens to the preview page. Dashboard AI UI should honor both global module visibility and AI feature flags. If AI features are disabled, hide AI buttons and tools throughout the admin UI. If AI is enabled but a capability lacks a model or key, show a clear disabled state instead of a failing action. ### Frontend AI Assistant The frontend Copilot is the logged-in editor chat widget loaded by `includes/footer.php` through `js/ai-copilot.js` and `css/ai-copilot.css`. It must remain an editor-facing orchestration layer, not a direct AI or file access layer. Core files: - `includes/ai/copilot-context.php` builds the safe page/content manifest, validates suggested field values, normalizes image paths, creates/publishes page/news/event drafts, and exposes role-derived Copilot permissions. It also exposes a compact `knowledgeBase` with curated Raiju guidance from local product/agent docs. Keep this summary bounded and intentional; do not stream arbitrary repository files, PHP templates, or raw server documentation into the frontend Copilot context. Standard-page `sections[]` entries should be traversed into concrete manifest paths such as `sections.0.content` and `sections.2.src`; do not collapse the whole sections array into a single AI target. Keep structural metadata out of writable Copilot targets: page/lang identity, timestamps, publication flags, section ids, section types, layout size/level, and variant/style controls should not be suggested or applied by chat. Visibility flags such as `field__hidden`, section `hidden`, and status fields should also stay out of generic field suggestions; hide/show must use the dedicated signed visibility action instead of an ordinary boolean proposal. - `admin/api.php` owns all Copilot actions: context, chat, field suggestions, HTML formatting, visibility previews/applies, confirmed field apply, undo, content draft/create/publish, and image generation. - `js/ai-copilot.js` renders the chat panel, preview cards, confirmations, image variant selection, alt-text editing, undo, and publish controls. If `window.RJ_ADMIN_API_URL` is missing, it must infer `admin/api.php` from its own script URL so nested frontend pages still post to the right endpoint. - `css/ai-copilot.css` styles the widget independently from public site CSS. - `admin/lang/*.json` should contain user-facing Copilot strings. The widget uses `window.RJ_LANG` with safe English fallbacks. Safety rules: - Never expose provider API keys, raw PHP templates, server files, password hashes, unrestricted JSON, or local provider internals to the model. - Browser code must only call `admin/api.php`; provider requests go through `includes/ai/ai-helper.php`. - Every write must require an authenticated session, CSRF, a Copilot permission, and a server-side manifest match. The model output is untrusted. - Previewed field and image proposals must carry a server-side signature over page, path, type, current hash, and allowed value hashes. Apply actions must reject unsigned proposals or values outside the signed preview. - Field updates are limited to manifest-supported field types. Text, HTML, link, boolean, known select values such as `seo.robots`, and image replacements have type-specific validation. - When a frontend editable link is selected, `js/ai-copilot.js` should resolve the instruction to the matching manifest subfield: `.href`/`.url` for target changes, `.text`/`.label`/`.title` for label changes. Do not apply AI output to the whole link object. - Simple selected-field HTML formatting requests such as bold, italic, and underline should use `ai-copilot-format-html` / `raijuCopilotBuildHtmlFormatProposal()` so they become signed preview proposals without spending a model call or bypassing confirmation. - Explicit selected-field hide/show requests should use `ai-copilot-visibility` and `ai-copilot-apply-visibility`. Sign the page/path/action/current-state, write only the matching `path__hidden` flag, create a backup, audit the hidden-state change, and return an Undo token bound to that hidden path. - HTML suggestions must pass the existing sanitizer. Links must reject unsafe protocols. Image paths must stay under `/assets/images/` and point to an existing raster media file (`jpg`, `jpeg`, `png`, `webp`, `gif`). Do not allow SVG replacement or reference images through the Copilot image workflow. - Image draft selection must stay inside the Copilot panel: editors choose the target image field and generate/edit mode before `ai-copilot-generate-image` is called. Do not use native `prompt()`/`confirm()` dialogs for this image selection flow. - Image draft provider options must also stay inside the Copilot panel and be allowlisted server-side before they reach the provider gateway. Current Copilot options are size/orientation, variant count, raster output format, and quality. - Confirmed page changes must create backups and return a signed, limited undo token for the same page/path. Do not turn the Copilot into a general backup browser. - Copilot proposal audit summaries should store path/type/current hash/value hash metadata, not raw generated values. Use `raijuCopilotProposalAuditSummary()` rather than logging proposal payloads directly. - Content creation must default to safe draft states: pages private/no nav, news hidden, events hidden. Publishing requires a separate explicit confirmation and Copilot publish permission. - Content draft previews must be signed before create actions accept them. Never accept a page/news/event creation payload that bypasses the preview signature. - Image generation is available only when the AI module, AI settings, image feature flag, image model, and target image field all allow it. Editors choose whether to use the current field image as an edit reference or generate a new image from prompt/context only. - Copilot actions should use `raijuCopilotAssertBurstLimit()` after session and CSRF checks for session-scoped burst protection. This complements the AI gateway's daily request, token, and budget limits; it does not replace them. - Write and image actions should call `raijuAiAudit()` with enough metadata to debug what happened without logging secrets. Verification for Copilot changes: ```bash php -l includes/ai/copilot-context.php && php -l includes/ai/ai-helper.php && php -l admin/api.php && php -l includes/footer.php node --check js/ai-copilot.js php tests/copilot-smoke.php node tests/copilot-api-smoke.js node tests/copilot-js-smoke.js php tests/copilot-i18n-smoke.php find . -maxdepth 3 \( -path './.git' -o -path './relaunch' \) -prune -o -name '*.json' -print | xargs -n 20 php -r 'foreach (array_slice($argv,1) as $f) { json_decode(file_get_contents($f), true); if (json_last_error() !== JSON_ERROR_NONE) { fwrite(STDERR, $f . ": " . json_last_error_msg() . PHP_EOL); exit(1); } }' git diff --check -- AI-COPILOT-GOAL.md AI-AGENT-GUIDE.md includes/ai/copilot-context.php includes/ai/ai-helper.php admin/api.php includes/footer.php js/ai-copilot.js css/ai-copilot.css tests/copilot-smoke.php tests/copilot-api-smoke.js tests/copilot-js-smoke.js tests/copilot-i18n-smoke.php admin/lang/*.json ``` When a configured local site is available, also run a browser check as a logged-in editor: confirm that the widget appears only when AI is available, field suggestions show preview cards before saving, apply creates a backup and undo works, page/news/event creation starts as draft/private/hidden, publish requires confirmation, and image generation respects the edit-vs-generate choice. ### AI Agent (Frontend Copilot) The agent is an optional mode of the assistant chat: on the website (the frontend Copilot) and, since 1.0.0, in the dashboard, where the "Assistant" card of the home tab embeds the chat (`RaijuAiCopilot.embed(host)`; `syncAssistantEmbed()` in `admin/dashboard/scripts/overview-ai.php` moves it between the card and the floating bubble when the tab changes). `js/ai-agent.js` reads `window.RJ_AI_AGENT` (set by `includes/footer.php` and `admin/dashboard.php` under the same conditions) and remembers its mode apart for the two. The editor describes a goal in the chat; the server works through it with a fixed set of tools, stages all changes in a **change set** (a package of operations) and presents it. The editor reviews it (before/after, images, sources, checks), can open a preview and applies it with one confirmed click. Nothing is written live by the agent. Classic Copilot flows stay available through the Agent/Classic switch and are the fallback when the provider cannot call tools. The agent is off by default. Admins enable it in Dashboard → Settings → AI Settings → "AI agent" (limits per run, what editors may do, Brave and Pexels keys, media folder, credit behaviour, "clear page scan cache"). It needs a provider/model with tool calling (OpenAI-compatible and Anthropic are supported; Kie.ai is not) and the agent code reads only the `agent`, `research` and `stock` sections through `raijuAiReadStoredSettings()` (settings file with `content/ai-secrets.json` merged in; never read the files directly). API keys of these sections are never returned by `load-ai-settings`; an empty key field on save keeps the saved key. Files (all under `includes/ai/agent/` unless noted): | File | Role | |---|---| | `agent-config.php`, `settings.php` | Settings, rights (`raijuAgentCan()`), availability. Light enough for every page view. | | `tool-gateway.php` | `raijuAiChatWithTools()`: tool calling for OpenAI and Anthropic with argument validation, paired history, per-turn request ledger. | | `net.php` | `raijuNetSafeFetch()`: the only way to fetch external URLs (public addresses only, pinned DNS, manual redirects, size/type limits). | | `render-scan.php`, `capabilities.php`, `schemas.php` | Render scan of a page template to a capability map (fields, lists, limits). Site overrides in `content/ai-schema/`. | | `changeset*.php`, `preview.php` | Operations, validation, render check, apply with locks/backups/journal, undo, publish, owner-only preview. | | `run.php`, `tools.php`, `prompt.php` | The loop: step requests from the browser, tool whitelist, system prompt. | | `safety.php`, `safety-patterns.php` | The safety check: blast radius of a change set, new text, swapped contact details, first-line screening of requests. Word lists for the nine languages. | | `../raiju-map.php` | Where things are in the dashboard (see "Raiju map"); used by `find_in_dashboard` and the dashboard assistant. | | `media.php` | Image ingest (re-encoding, metadata removal), staging, stock photos, credits. | | `research.php` | Brave search, page extraction, the tool-less reading step, sources. | | `admin/api/agent.php` | The `ai-agent-*` actions. | | `js/ai-agent.js` | Cards, timeline, upload, resume; plugs into `js/ai-copilot.js` through `registerAgent()`. | How a run works: the browser calls `ai-agent-start`, then `ai-agent-step` repeatedly (each step does one model turn or executes pending tool calls and is safe against parallel steps). State and transcript live on the server in `content/ai-agent/runs/`; the browser only sends ids and the user's own words. Slow tools end the step (web reads); image generation waits in `waiting_jobs`. Limits per round (turns, tool calls, failures, runtime) and a cost cap per run apply on top of the gateway's daily and monthly limits. Tools (`raijuAgentToolDefinitions()`): read tools (`get_site_overview`, `find_in_dashboard`, `get_page_capabilities`, `read_content`, `list_content`, `list_media`, `list_run_media`), research (`search_web`, `read_web_result`, `read_url`), images (`search_stock_images`, `import_stock_image`, `generate_image`), stage tools (`stage_set_field`, `stage_set_image`, `stage_add_list_item`, `stage_hide_list_item`, `stage_remove_list_item`, `stage_move_list_item`, `stage_add_section`, `stage_create_news`, `stage_create_event`, `stage_create_page`, `stage_revert_op`) and the flow tools (`validate_changeset`, `ask_user`, `present_changeset`). Every tool has a JSON schema, a right and a handler; no tool takes a file path, runs code or writes outside the change set. A new tool needs: a definition with schema and right, a handler, an `agent.step.` text in all `admin/lang/*.json`, an entry in `TOOL_STEPS` of `js/ai-agent.js`, and a test. Security rules for agent work (read before touching `includes/ai/agent/`): - **Safety check (`safety.php`) judges effects, not words.** `raijuAgentSafetyAssess()` compares the replayed content with what is stored (before/after per file) and runs inside `raijuAgentValidate()`, which staging, finalize and apply all call. Never move a check into a tool handler or the prompt, never trust flags stored in an operation, and never let a new operation type skip it: the amounts (removed, hidden, cleared parts; lost text; created and touched targets; menu changes) are summed over the whole change set, so splitting a deletion into many small operations does not help. `block` findings are errors (no `ready`, no present, no apply, no override for anyone); `caution` findings are warnings with a `safety_` code, shown in the review, and `ai-agent-apply` needs `confirmRisk=1` for them. New text is scanned only where it differs from the stored text. Add words to `safety-patterns.php` (folded text: lowercase, no accents), never inline; add a test case to `tests/agent-safety-smoke.php` for every new rule. The limits live in `RAIJU_AGENT_SAFETY_LIMITS` and are lowered, not raised. The patterns are a heuristic, not a moderation service; the amounts are the part that does not depend on wording. `raijuAgentSafetyScreenRequest()` refuses obvious requests before the model is called (agent start and replies, assistant chat); it is a first line, not the enforcement. - The classic Copilot uses the same word lists through `raijuCopilotSafetyJudge()` (`raijuAgentSafetyJudgeTexts()`): `raijuCopilotValidateProposals()` drops harmful suggestions before they are signed (the `$report` argument counts them, the endpoints answer with `copilot.safety_blocked` when nothing is left) and adds `safetyNote` to odd ones; `raijuCopilotNormalizeCreateDraft()` refuses harmful drafts and adds `safetyNote`. Only the text check applies there: every suggestion is one field, previewed and signed. - Every code in `safety.php` (`$add('mass_removal', …)`) needs `agent.safety.{code}` in all nine languages; `agent.safety_refuse.{category}` for refusals. Audit entries (`agent-safety`) carry the stage and codes or category, never text. - The model output and everything it reads (page content, search results, web pages, image metadata) is untrusted. Tool results are data, never instructions; the system prompt says so and the tools enforce it. - Web pages are never shown to the agent. The server fetches a result with `raijuNetSafeFetch()` (robots.txt, domain lists, no own host), extracts text with DOMDocument and gives it to a separate model call **without tools** that may only answer with a strict JSON schema. URLs, code, instructions and quotes that are not in the page are dropped; instruction-like pages get a warning on the source. The agent receives only that JSON, marked untrusted. - `read_url` reads a page whose address the **user typed** in this run (goal or replies), matched against the user's own messages; addresses the model found, built or took from a page are refused. It needs no search key, goes through the same safe fetch, robots.txt, domain lists, reading step and warnings, and counts towards the page-read limit. - External links in written content are allowed only when the user typed the address or a source of the run was actually fetched. The rule also covers links inside rich text. - Targets are keys checked by regex and the capability map (`page:{key}`, `news:{id}`, `event:{id}`); no paths, no `__proto__`-style keys, no structural fields such as `visibility.passwordHash`. Private pages enter the context only when the user names them, and their password hash never does. - New news, events and pages are created hidden/private; list items and sections start hidden. Publishing is a separate right and a separate click. - Images are validated (type, size, pixels), re-encoded without metadata and staged in `content/ai-agent/staging/`; only images that the applied package uses move into `assets/images/`, with credit and AI label. Uploaded SVG and GIF are refused. The AI label is read before re-encoding. - Every state change is audited (`raijuAgentAudit()`): ids, hashes and counts, never goal text, page content, fetched text or keys. Search queries are logged readably because they leave the server. - All actions are POST with CSRF except the read-only `ai-agent-status`, `ai-agent-changeset` and `ai-agent-media` (GET); runs, change sets and staged images belong to their owner (admins see all); rate limits apply per user. - `content/ai-agent/` must stay non-public (it lives under `content/`, which `.htaccess` and the dashboard security check cover). Do not log it, do not link to it, and do not expose staging paths to the browser (the review shows thumbnails through `ai-agent-media`). - Test-only network redirection (`RAIJU_AGENT_TEST_NET`, `testTransport`, ...) works only under the CLI and the built-in server. Site builders can restrict what the agent may do per page with `content/ai-schema/` (see SKILLS.md → "ai-schema-override"). Overrides only restrict: they can lower limits, mark paths read-only, require fields and add hints; they cannot make something editable. Verification for agent changes: ```bash python3 tests/run-smoke.py # includes all agent-*-smoke suites python3 tests/agent-browser-check.py # Chromium (Playwright): panel, review, apply, undo, settings node --check js/ai-agent.js ``` The agent suites in `tests/`: `agent-safety` (PHP and HTTP), `raiju-map`, `agent-net`, `agent-scan-mode`, `agent-gateway`, `agent-capabilities`, `agent-changeset`, `agent-preview`, `agent-tools`, `agent-loop`, `agent-media-ingest`, `agent-media`, `agent-research-extract`, `agent-research`, `agent-settings` (PHP and HTTP), `agent-rate-limit`, `agent-image-label`, `agent-api`, `agent-js` and `agent-security`, plus the Copilot i18n check for the `agent.*` and `ai.agent_*` texts in all nine languages. ## Admin UI Conventions Recent dashboard work tightened several admin surfaces. Preserve these patterns when adding related UI: - **Content editor sections**: the left section jump menu is sticky while scrolling, supports search and type filtering, keeps long labels clamped, and greys out filtered/non-jumpable items. Keyboard section navigation should keep the active marker stable after programmatic scrolls. - **Section editor cards**: type badges should look like metadata, not primary action buttons. Insert controls between filtered-out sections should not leave large empty gaps. - **Messages**: the page is explicitly labelled as contact/form submissions. Header copy sits with the title, actions align as a toolbar, and the list can filter by form. - **Settings forms**: prefer clear native controls (`select`, checkboxes, buttons) over card-like pseudo-buttons when the interaction is selection. Compact editor rows should use normal label casing, aligned controls, and no horizontal overflow. - **Media and branding previews**: transparent images should be rendered on a visible checkerboard/transparency surface in dark mode. - **Dashboard stats**: stat labels align at the top of their cards and numbers align at the bottom for quick scanning. - **AI dashboard panel**: hide disabled AI tools. If AI services are disabled or unconfigured, show only a compact dismissible notice where appropriate. ### Dashboard design system The dashboard is built from one token set and five shared patterns. Keep to them when adding admin UI: - **Tokens live in `css/raiju-admin-tokens.css`**, prefixed `--rj-*`. Spacing (`--rj-space-1 … 12`, with half steps like `--rj-space-2-5`), a six-step type scale (`--rj-text-xs … 2xl`), one control height for inputs, selects and buttons (`--rj-control-h`, `--rj-control-h-sm`), radii and shell geometry (`--rj-sidebar-w`, `--rj-topbar-h`, `--rj-content-max`). Write new admin CSS with these tokens, never with pixel values. - **Everything scales with the root font size.** `admin/style.css` sets `html { font-size: clamp(14px, …, 18px) }`, and `--rj-d` (density, from the viewport or the user's "Dichte" setting via `data-density` on ``) multiplies spacing and control heights. Do not reintroduce fixed widths for the sidebar, topbar or container. - **One base colour.** `theme.primaryColor` is the base; the stylesheet derives primary, hover, tints, the accent (`--rj-accent-base`, overridable through `theme.accentColor`) and, per preset, the surfaces in OKLCH relative colour syntax inside `@supports (color: oklch(from red l c h))`. Dark mode lifts the base to at least `l 0.72`; nothing stores a separate dark colour any more (`darkPrimaryColor` / `darkAccentColor` are accepted but ignored). - **Semantic colours.** `theme.dangerColor`, `theme.warningColor` and `theme.successColor` (empty = auto) reach the stylesheet as `--rj-danger-base`, `--rj-warning-base`, `--rj-success-base`; the tokens file derives hover, subtle and text shades from them in OKLCH, light and dark. Success follows the primary colour unless set. Console's and Pop's preset defaults set all three along with the base colour. - **Presets** (`theme.preset`: `soft` default, `classic`, `paper`, `studio`, `console`, `pop`) set surfaces, borders, radii, control heights and the sidebar/topbar tokens and bring a font (Soft Inter, Classic Public Sans, Paper Atkinson Hyperlegible, Studio IBM Plex Sans, Console Geist Mono, Pop Atkinson Hyperlegible) (`--rj-sidebar-*`, `--rj-topbar-*`). Admin CSS reads those tokens and never hard-codes chrome colours; the few per-preset component rules (table heads, button surfaces, sidebar item geometry) live in the "PRESET REFINEMENTS" block at the end of `admin/style.css`. Two presets follow published references: **Soft** uses the shadcn/ui measures (zinc greys, 36 px / 32 px controls, 8 px cards / 6 px controls, 40 px table heads in muted text) on a faintly tinted shell; **Studio** follows the Vercel dashboard / Geist (pure neutral greys, `#eaeaea` hairlines, black buttons, white on black in dark mode, 40 px / 32 px controls, 6 px radius). In Studio the base colour is an accent only (focus ring, success, badges), so `--rj-primary` is near-black there and must not be assumed to carry the brand hue. **Console** is the ops-console look: Geist Mono as the UI face (`theme.font: geist-mono`), a smaller type scale, caps with tracking on labels, heads, navigation and buttons, square corners, dashed group dividers, and a crosshair on selected elements (four corner brackets drawn by one `::after` with eight gradient strokes; see `--rj-xh-*` in the Console block). The base colour keeps its full chroma there. **Pop** is the playful one: sidebar and topbar in a deep slate derived from the base hue (`--rj-sidebar-bg`), the sidebar running the gradient token `--rj-sidebar-gradient` (built from `--rj-sidebar-bg`, so a custom sidebar colour keeps the gradient); a light stage with white cards and soft shadows; legends, panel titles, stat and status labels as pills (`--rj-pop-pill` for the colour, `--rj-pop-pill-text` for the text on it, stat tiles cycling base/danger/warning/success/accent through `nth-child`); second-level labels as tinted pills; round navigation and swatches. Its preset defaults set the four palette colours like Console does. `` carries `data-theme-preset`, `data-density` and `data-font`; `admin/dashboard.php` emits `--rj-base` and optional sidebar overrides inline, `applyTheme()` in `admin/dashboard/scripts/settings.php` mirrors that live. - **Collapsible sidebar.** `` shrinks the sidebar to its icons (`--rj-sidebar-w` 3.5rem, labels hidden, badges in the corner, tooltips from the label text). The state is a per-browser preference in `localStorage` (`raijuSidebarCollapsed`), set by a head script before first paint and toggled by `#sidebarCollapseBtn` at the sidebar's foot; the mobile drawer below 769px ignores it. - **Fonts are local.** `theme.font` is `system` (default, no file), `inter`, `plex` (IBM Plex Sans), `atkinson` (Atkinson Hyperlegible Next), `public` (Public Sans) or `geist-mono`; a stored `geist` reads as `plex`. `theme.fontSize` (`small`, `default`, `large`, `data-font-size` on ``) scales the type steps only (`--rj-font-scale`), never spacing or control heights; faces are declared in `admin/fonts.css` from `admin/fonts/`. Never link an external font service in the admin. - **Feature stylesheets** (`css/ai-copilot.css`, `css/image-manager.css`) are loaded on the public site too, so they read tokens with fallbacks: `var(--rj-space-3, 12px)`, `var(--rj-text, #172033)`. Keep that form there. In dark mode the primary colour is light, so anything on it uses `--rj-text-on-primary` (dark in dark mode), never a literal white. - **Patterns**: every tab starts with `.page-list-header` (title + description left, `.page-list-header-actions` right, one `.btn-primary`); the topbar shows the site name and a breadcrumb via `rjSetTopbarTitle()`; cards have at most two levels (fieldsets inside a settings card are groups with a divider, not a second box); settings forms use label-left rows from 1100px and end in a sticky `.settings-actions` bar; lists share `.page-list-table`, hover/focus row actions and `rjRenderEmptyRow()`; dates go through `rjFormatDate()`. ### Select carets Load `css/rj-select.css` for admin/editor controls. Enhanced dropdowns and `select[data-native-select]` share a 14px caret inset through `--rj-select-caret-inset`. The native attribute retains the browser picker and keyboard behavior, while CSS draws the closed-state caret with reserved text space. Do not restore `appearance: auto` or replace its background shorthand in component styles. Multiple/size listboxes are excluded; forced-colors mode restores the system arrow. RTL fields move the caret to the left automatically. Use `--rj-select-caret-image` only when a theme needs a different caret color. Admin CSS is cache-busted from `admin/dashboard.php` with `filemtime()` so layout changes in `admin/style.css` are visible after reload. ## Multilingual Admin Editor Fields Admin/editor dialogs can group language variants of the same field into tabs. This is a Core UI feature for editor modals and structured admin editors, not for public visitor forms. Use either a language-object field with `localized: true`: ```php [ 'label' => 'Event', 'fields' => [ ['key' => 'title', 'type' => 'input', 'label' => 'Title', 'localized' => true], ['key' => 'description', 'type' => 'textarea', 'label' => 'Description', 'localized' => true], ['key' => 'url', 'type' => 'url', 'label' => 'URL'], ], ] ``` or explicit language-suffixed paths such as `title.de`, `title.en`, `description.de`, and `description.en`. Raiju reads language codes from `$SITE_LANGUAGES`; one-language sites render the fields normally without tabs. Fields that are not language-specific remain outside the tabs. The tab UI is a rendering layer only, so saving keeps the same JSON paths and data shape. When the AI module is enabled, multilingual editor tabs show a translation button for the active source language. The generated translations are written only into the still-open dialog fields and are persisted only if the editor uses the normal Save action. ## Accessibility Best Practices Raiju Core provides the baseline accessibility plumbing for public pages and admin UI: a skip link, `main-content` target handling, landmark-friendly header and footer navigation, visible focus states, reduced-motion CSS, labelled toolbar controls, modal dialog semantics, live regions for toast/status messages, and SEO health checks for missing image alt text. When creating or adapting site templates, preserve that foundation: - Use one primary `
` element per page. Standard JSON pages get `id="main-content"` automatically; custom templates should add that id or include `includes/footer.php` so the fallback can attach it to the first `
`. - Keep heading hierarchy semantic. Use one visible H1 for the page topic, then H2/H3 sections in order. Do not choose heading levels only for visual size. - Provide meaningful alt text for informative images. Use an empty alt (`alt=""`) only for decorative images. The page SEO health indicator reports missing image alt text. - Use native elements: `