A safe, structured interface between AI agents and your Statamic application.
Table of Contents
- Overview
- Why This Addon Exists
- Core Philosophy
- Architecture
- Installation
- Enabling the Addon
- Control Panel Settings
- Endpoints
- Configuration Reference
- Security Model
- Confirmation Flow
- Audit Logging
- Tool Reference
- Request / Response Contract
- Capability Discovery
- Internal Components
- Testing
- Extending with Custom Tools
- When Should You Use This?
- When This Might Not Be For You
- Minimal Setup Example
- Agent Setup Guide
- Future Direction
Overview
AI Gateway is a Statamic v6 addon (stokoe/ai-gateway) that provides a controlled, authenticated HTTP tool execution interface for AI agents. The addon acts as a gateway layer between external AI systems and your Statamic/Laravel application, mediating all AI-initiated content mutations and operational actions through a strict pipeline of authentication, validation, authorization, tool resolution, execution, and audit logging.
Rather than exposing your application directly, this addon introduces a tool-based execution layer. AI agents can request actions such as creating content, updating globals, modifying navigation, or clearing caches — and the addon decides whether, how, and when those actions are allowed to happen.
This makes it possible to bring AI-assisted workflows into Statamic without compromising stability, security, or developer control.
Why This Addon Exists
AI integrations are powerful — but they can also be dangerous if given unrestricted access.
Most approaches fall into one of two categories:
- Direct access to application internals (risky, hard to control)
- Large tool surfaces with many granular actions (complex, error-prone)
This addon takes a different approach:
A small, well-defined set of safe actions, enforced by strict rules.
Instead of asking "what can the AI do?", this addon answers:
"What is the AI allowed to do safely?"
Core Philosophy
1. Controlled access, not full access
Your agent does not get direct access to your application. Instead, it interacts through named tools, each with explicit inputs, strict validation rules, and clear boundaries.
2. Safe by default
Everything is locked down unless you explicitly allow it. Tools must be enabled. Targets must be allowlisted. Fields can be restricted. Risky operations can require confirmation. If something isn't explicitly allowed, it doesn't happen.
3. Designed for production
This is not just a development tool — it's built to run safely in real environments. That means structured request/response contracts, predictable error handling, rate limiting, audit logging via Laravel's logging system, and confirmation flows for sensitive operations.
4. Fewer, smarter tools
Instead of exposing dozens of granular operations, this addon provides a small set of high-level actions. For example, entry.upsert instead of separate create/update flows, and cache.clear instead of multiple low-level commands. This makes it easier for agents to choose the right action, avoid mistakes, and behave predictably.
5. Framework-native execution
All actions are executed using Statamic and Laravel APIs, not direct file or system manipulation. This ensures compatibility with your existing setup, respect for blueprints and content structure, and consistency with how your application normally behaves.
Architecture
Key Design Decisions
- In-process addon over separate service — Direct access to Statamic APIs, simpler deployment, no cross-service auth sync needed.
- Tool gateway pattern — Named tools with structured arguments rather than generic remote access. Each capability is intentionally added.
- Configuration-driven allowlisting — Default-deny posture where every tool, target, and field must be explicitly permitted.
- Synchronous execution only (v1) — Simpler mental model, immediate feedback, appropriate for expected operation volume.
- Stateless confirmation tokens — HMAC-signed tokens for production confirmation flow, avoiding database persistence.
- Single execution endpoint — All tools invoked through one route, keeping the API contract minimal and consistent.
High-Level Architecture
flowchart LR A[AI Agent] -->|HTTP POST/GET| B[Laravel Router] B --> C[AuthenticateGateway Middleware] C --> D[EnforceRateLimit Middleware] D --> E[ToolExecutionController] E --> F[Request Envelope Validation] F --> G[ToolRegistry] G --> H[Policy Layer - Authorization] H --> I[Tool Handler] I --> J[Statamic/Laravel APIs] I --> K[AuditLogger] K --> L[Laravel Log Channel] J --> M[Content Store / Cache]
Request Pipeline
sequenceDiagram participant AG as AI Agent participant MW as Middleware Stack participant CTRL as ToolExecutionController participant REG as ToolRegistry participant POL as Policy Layer participant TOOL as Tool Handler participant STA as Statamic/Laravel participant LOG as AuditLogger AG->>MW: POST /ai-gateway/execute MW->>MW: AuthenticateGateway (bearer token) MW->>MW: EnforceRateLimit (per-token bucket) MW->>CTRL: Authenticated request CTRL->>CTRL: Validate request envelope CTRL->>REG: Resolve tool by name REG-->>CTRL: Tool handler instance CTRL->>POL: Authorize (tool-level, target-level, field-level) POL-->>CTRL: Authorized / Denied CTRL->>CTRL: Check confirmation flow (if production + gated) CTRL->>TOOL: Execute with validated arguments TOOL->>STA: Perform operation STA-->>TOOL: Result TOOL-->>CTRL: ToolResponse CTRL->>LOG: Record audit event CTRL-->>AG: JSON Response Envelope
Module Structure
addons/stokoe/ai-gateway/├── src/│ ├── ServiceProvider.php│ ├── Http/│ │ ├── Controllers/│ │ │ └── ToolExecutionController.php│ │ └── Middleware/│ │ ├── AuthenticateGateway.php│ │ └── EnforceRateLimit.php│ ├── Tools/│ │ ├── Contracts/│ │ │ └── GatewayTool.php│ │ ├── EntryCreateTool.php│ │ ├── EntryUpdateTool.php│ │ ├── EntryUpsertTool.php│ │ ├── GlobalUpdateTool.php│ │ ├── NavigationUpdateTool.php│ │ ├── TermUpsertTool.php│ │ └── CacheClearTool.php│ ├── Support/│ │ ├── ToolRegistry.php│ │ ├── ToolResponse.php│ │ ├── ConfirmationTokenManager.php│ │ ├── AuditLogger.php│ │ └── FieldFilter.php│ ├── Policies/│ │ └── ToolPolicy.php│ └── Exceptions/│ ├── ToolNotFoundException.php│ ├── ToolDisabledException.php│ ├── ToolAuthorizationException.php│ └── ToolValidationException.php├── config/│ └── ai_gateway.php├── routes/│ └── api.php└── tests/ ├── TestCase.php ├── Property/ (11 property-based tests) ├── Unit/ (addon lifecycle tests) └── Integration/ (full pipeline tests)
How the Pipeline Works
At a high level:
- Your agent sends a JSON request describing what it wants to do
- The
AuthenticateGatewaymiddleware validates the bearer token usinghash_equals()for timing-safe comparison - The
EnforceRateLimitmiddleware checks per-token rate limits - The
ToolExecutionControllervalidates the request envelope (content type, size, schema) - The
ToolRegistryresolves the tool name to a handler class - The
ToolPolicychecks tool-level authorization (is the tool enabled?) and target-level authorization (is the target in the allowlist?) - The
FieldFilterstrips any denied fields from the payload - The
ConfirmationTokenManagerchecks whether confirmation is required (production safety) - Tool-specific validation runs against the handler's declared rules
- The tool executes through Statamic/Laravel APIs
- The
AuditLoggerrecords the operation - A structured JSON response is returned
All of this happens through a single, consistent interface.
Installation
The addon is installed as a local package at addons/stokoe/ai-gateway/. To publish the configuration file:
php artisan vendor:publish --tag=ai-gateway-config
This copies ai_gateway.php into your application's config/ directory, where you can customise it.
Enabling the Addon
The addon is disabled by default. When disabled, it registers no routes, no bindings — it is completely invisible. Requests to /ai-gateway/* return Laravel's standard 404, leaking no information about the addon's existence.
Add to your .env:
AI_GATEWAY_ENABLED=trueAI_GATEWAY_TOKEN=your-secret-token-here
AI_GATEWAY_TOKEN is the bearer token that all requests must include. Generate a strong, random string — this is your only authentication layer. The token is compared using hash_equals() to prevent timing attacks.
How the Kill Switch Works
The ServiceProvider merges config in register(), then checks ai_gateway.enabled in bootAddon(). When disabled, it returns immediately — no routes are loaded, no singletons are bound. The addon is architecturally invisible.
public function bootAddon(): void{ if (! config('ai_gateway.enabled')) { return; } // ... routes, singletons, publishable config}
Control Panel Settings
The addon includes a full settings panel in the Statamic Control Panel, so you can configure everything from the browser without touching .env files or config files.
Accessing the Settings Panel
Once the addon is installed, an "AI Gateway" item appears in the CP sidebar under Tools. Click it to open the settings page. The panel is available even when the gateway API is disabled — you need to be able to turn it on from somewhere.
Only super admins can access the settings panel. Regular CP users won't see the nav item, and direct URL access returns a 403.
What You Can Configure
The settings panel covers every configuration option:
- General — Master enable/disable toggle and bearer token management (masked display, reveal, and one-click generation of cryptographically random 64-character hex tokens)
- Rate Limits — Requests per minute for the execute and capabilities endpoints
- Request Limits — Maximum request body size in bytes
- Tools — Individual enable/disable toggles for all 13 tools, grouped by type (entry, global, navigation, term, cache)
- Allowlists — Tag-input editors for collections, globals, navigations, and taxonomies, plus checkboxes for cache targets
- Field Deny Lists — Tag-input editors for entry, global, and term denied fields
- Confirmation Flow — Token TTL and per-tool environment rules (e.g. require confirmation for
cache.clearin production) - Audit — Log channel selection from your configured Laravel log channels
How Settings Persistence Works
Settings are saved to a YAML file at storage/statamic/addons/ai-gateway/settings.yaml. This keeps them:
- Version-controllable (if you include storage in your repo)
- Consistent with Statamic's flat-file philosophy
- Independent of the database
- Portable across environments
The file is created automatically on first save. The SettingsRepository handles reading, writing, and directory creation.
Configuration Precedence
The addon uses a layered configuration model:
- CP settings (YAML file) — highest priority, wins when present
- Published config (
config/ai_gateway.php) — mid-level, used when no CP value exists - Package defaults (addon's built-in config with
.envfallbacks) — lowest priority
This means:
- You can start with just
.envvariables and no CP configuration - Once you save something in the CP, that value takes over
.envvalues still work as fallbacks for anything you haven't configured in the CP- Deleting the YAML file resets everything back to
.env/config defaults
The merge happens at boot time via SettingsRepository::applyToConfig(), which runs array_replace_recursive() to merge YAML values over config defaults. All existing config('ai_gateway.*') calls throughout the codebase continue to work without modification.
Endpoints
Once enabled, two routes are available:
| Method | Path | Purpose |
|---|---|---|
| POST | /ai-gateway/execute |
Execute a tool |
| GET | /ai-gateway/capabilities |
Discover available tools and config |
Both require the Authorization: Bearer <token> header. Both pass through AuthenticateGateway and EnforceRateLimit middleware.
Configuration Reference
Full Configuration File
The complete config/ai_gateway.php:
return [ // Master kill switch — addon is invisible when false 'enabled' => env('AI_GATEWAY_ENABLED', false), // Bearer token for authentication 'token' => env('AI_GATEWAY_TOKEN'), // Maximum request body size in bytes (default 64KB) 'max_request_size' => env('AI_GATEWAY_MAX_REQUEST_SIZE', 65536), // Rate limits (requests per minute, per token) 'rate_limits' => [ 'execute' => env('AI_GATEWAY_RATE_LIMIT_EXECUTE', 30), 'capabilities' => env('AI_GATEWAY_RATE_LIMIT_CAPABILITIES', 60), ], // Tool enablement — all disabled by default 'tools' => [ 'entry.create' => env('AI_GATEWAY_TOOL_ENTRY_CREATE', false), 'entry.update' => env('AI_GATEWAY_TOOL_ENTRY_UPDATE', false), 'entry.upsert' => env('AI_GATEWAY_TOOL_ENTRY_UPSERT', false), 'global.update' => env('AI_GATEWAY_TOOL_GLOBAL_UPDATE', false), 'navigation.update' => env('AI_GATEWAY_TOOL_NAVIGATION_UPDATE', false), 'term.upsert' => env('AI_GATEWAY_TOOL_TERM_UPSERT', false), 'cache.clear' => env('AI_GATEWAY_TOOL_CACHE_CLEAR', false), ], // Target allowlists — empty by default (nothing permitted) 'allowed_collections' => [], 'allowed_globals' => [], 'allowed_navigations' => [], 'allowed_taxonomies' => [], 'allowed_cache_targets' => [], // Field-level deny lists per target type 'denied_fields' => [ 'entry' => [], 'global' => [], 'term' => [], ], // Confirmation flow 'confirmation' => [ 'ttl' => env('AI_GATEWAY_CONFIRMATION_TTL', 60), // seconds 'tools' => [ 'cache.clear' => ['production'], ], ], // Audit logging 'audit' => [ 'channel' => env('AI_GATEWAY_LOG_CHANNEL', null), // null = default channel ],];
Environment Variables
| Variable | Default | Description |
|---|---|---|
AI_GATEWAY_ENABLED |
false |
Master kill switch |
AI_GATEWAY_TOKEN |
null |
Bearer token for authentication |
AI_GATEWAY_MAX_REQUEST_SIZE |
65536 |
Max request body in bytes |
AI_GATEWAY_RATE_LIMIT_EXECUTE |
30 |
Requests/min for execute endpoint |
AI_GATEWAY_RATE_LIMIT_CAPABILITIES |
60 |
Requests/min for capabilities endpoint |
AI_GATEWAY_TOOL_ENTRY_CREATE |
false |
Enable entry.create tool |
AI_GATEWAY_TOOL_ENTRY_UPDATE |
false |
Enable entry.update tool |
AI_GATEWAY_TOOL_ENTRY_UPSERT |
false |
Enable entry.upsert tool |
AI_GATEWAY_TOOL_GLOBAL_UPDATE |
false |
Enable global.update tool |
AI_GATEWAY_TOOL_NAVIGATION_UPDATE |
false |
Enable navigation.update tool |
AI_GATEWAY_TOOL_TERM_UPSERT |
false |
Enable term.upsert tool |
AI_GATEWAY_TOOL_CACHE_CLEAR |
false |
Enable cache.clear tool |
AI_GATEWAY_CONFIRMATION_TTL |
60 |
Confirmation token lifetime in seconds |
AI_GATEWAY_LOG_CHANNEL |
null |
Laravel log channel for audit (null = default) |
Security Model
Three Layers of Authorization
The addon enforces a default-deny security model with three layers:
Layer 1 — Tool-level: Each tool must be explicitly enabled in config. A disabled tool returns 403 tool_disabled.
Layer 2 — Target-level: Even with a tool enabled, it can only operate on explicitly allowed targets. The ToolPolicy maps each tool's target type to its corresponding allowlist:
| Tool target type | Config key |
|---|---|
entry |
ai_gateway.allowed_collections |
global |
ai_gateway.allowed_globals |
navigation |
ai_gateway.allowed_navigations |
taxonomy |
ai_gateway.allowed_taxonomies |
cache |
ai_gateway.allowed_cache_targets |
A request targeting something not in the allowlist returns 403 forbidden.
Layer 3 — Field-level: Even within allowed targets, specific fields can be denied:
'denied_fields' => [ 'entry' => ['slug', 'date', 'author', 'blueprint'], 'global' => ['site_secret_key'], 'term' => [],],
Denied fields are silently stripped from the data payload before the tool executes. The caller is not notified — the fields simply don't get written. This happens after target-level authorization and before tool execution.
Authentication
All requests require an Authorization: Bearer <token> header. The AuthenticateGateway middleware:
- Rejects missing headers →
401 unauthorized - Rejects malformed headers (not
Bearer <token>) →401 unauthorized - Compares tokens using
hash_equals()for timing-safe comparison →401 unauthorizedon mismatch - Loads the expected token from
config('ai_gateway.token'), never from hardcoded values
Rate Limiting
The EnforceRateLimit middleware uses Laravel's RateLimiter facade with per-token buckets:
- Execute endpoint:
ai_gateway.rate_limits.execute(default 30/min) - Capabilities endpoint:
ai_gateway.rate_limits.capabilities(default 60/min) - Rate limit key is derived from a SHA-256 hash of the bearer token
- Exceeding the limit returns
429 rate_limited
Production Safety
In production (APP_ENV=production):
- Stack traces and filesystem paths are never included in error responses
- Exception class names are not exposed
- The
error.messageforinternal_erroris generic: "An internal error occurred" - Full exception details are written to the audit log for operator diagnosis
- The
cache.cleartool requires a two-step confirmation flow by default
Request Size Limit
The maximum request body size defaults to 64KB:
AI_GATEWAY_MAX_REQUEST_SIZE=65536
Oversized requests are rejected with 422 validation_failed before any processing occurs.
Confirmation Flow
Sensitive operations can require a two-step confirmation in specific environments. By default, cache.clear requires confirmation in production:
'confirmation' => [ 'ttl' => env('AI_GATEWAY_CONFIRMATION_TTL', 60), 'tools' => [ 'cache.clear' => ['production'], ],],
How It Works
- Agent calls a confirmation-gated tool without a token
- The addon returns
confirmation_requiredwith a signed, short-lived token - Agent resends the exact same request with the
confirmation_tokenfield - The addon validates the token and executes the tool
First request → receives token:
{ "ok": false, "error": { "code": "confirmation_required", "message": "This operation requires explicit confirmation in production." }, "confirmation": { "token": "base64-encoded-hmac-token", "expires_at": "2026-04-14T12:05:00+00:00", "operation_summary": { "tool": "cache.clear", "target": "static", "environment": "production" } }, "meta": {}}
Second request → include the token:
{ "tool": "cache.clear", "arguments": { "target": "static" }, "confirmation_token": "base64-encoded-hmac-token"}
Token Implementation
Tokens are generated by the ConfirmationTokenManager using HMAC-SHA256:
- Signing input:
tool_name | canonical_arguments_json | timestamp - Signing key: Laravel's
APP_KEY - Token format:
base64(timestamp.signature) - Expiry: Checked by extracting the embedded timestamp — no database required
- Binding: Tokens are bound to the exact tool + arguments they were issued for. A token for
cache.clearwithtarget: "static"will not validate fortarget: "stache"
This prevents:
- Accidental destructive actions
- One-step execution of sensitive operations
- Token reuse across different operations
- Unintended automation loops
Audit Logging
Every request to the execute endpoint is logged — including rejected requests. Logs go through Laravel's logging system.
AI_GATEWAY_LOG_CHANNEL=stack
Set to null (default) to use your application's default log channel.
What Gets Logged
Each log entry (written as ai_gateway.audit via Log::info()) includes:
| Field | Description |
|---|---|
request_id |
Client-provided tracking ID (if any) |
idempotency_key |
Client-provided dedup key (if any) |
tool |
Tool name that was invoked |
status |
succeeded, failed, or rejected |
http_status |
HTTP status code of the response |
target_type |
entry, global, navigation, taxonomy, cache |
target_identifier |
The specific target (e.g. collection handle) |
environment |
Application environment |
duration_ms |
Request processing time in milliseconds |
error_code |
Error code (when applicable) |
What Never Gets Logged
The AuditLogger explicitly strips these sensitive keys:
- Bearer tokens / authorization headers
- Confirmation tokens
- Raw request payloads
- Passwords and secrets
Tool Reference
entry.create
Creates a new entry in a collection. Returns 409 conflict if the entry already exists.
{ "tool": "entry.create", "arguments": { "collection": "pages", "slug": "hello-world", "site": "default", "published": true, "data": { "title": "Hello World", "content": "Welcome to our site." } }}
| Argument | Required | Type | Default | Notes |
|---|---|---|---|---|
collection |
yes | string | Must be in allowed_collections |
|
slug |
yes | string | Unique within collection + site | |
data |
yes | object | Field values; validated against blueprint | |
published |
no | boolean | Publish state of the entry | |
site |
no | string | "default" |
For multi-site setups |
- Checks collection exists via
Collection::findByHandle()→404 resource_not_found - Checks entry doesn't already exist →
409 conflict - Validates
dataagainst the collection's blueprint when available - Creates via
Entry::make()
entry.update
Updates an existing entry. Merges the provided data onto the existing entry — only the fields you send are changed, everything else is preserved.
Same arguments as entry.create. Returns 404 resource_not_found if the entry doesn't exist.
entry.upsert
Creates the entry if it doesn't exist, updates it if it does. Returns status: "created" or status: "updated". Same arguments as entry.create.
This is the safest choice for most content operations — no need to check existence first, no risk of conflict errors.
global.update
Updates a global variable set's localized values for a given site.
{ "tool": "global.update", "arguments": { "handle": "contact_information", "site": "default", }}
| Argument | Required | Type | Default | Notes |
|---|---|---|---|---|
handle |
yes | string | Must be in allowed_globals |
|
data |
yes | object | Field values as key-value pairs | |
site |
no | string | "default" |
For multi-site setups |
- Finds global set via
GlobalSet::findByHandle()→404 resource_not_found - Gets or creates localized variables for the site
navigation.update
Replaces an entire navigation tree. This is a full replacement — the existing tree is discarded entirely.
{ "tool": "navigation.update", "arguments": { "handle": "main_navigation", "site": "default", "tree": [ { "url": "/", "title": "Home" }, { "url": "/about", "title": "About" }, { "url": "/contact", "title": "Contact" } ] }}
| Argument | Required | Type | Default | Notes |
|---|---|---|---|---|
handle |
yes | string | Must be in allowed_navigations |
|
tree |
yes | array | Complete navigation structure | |
site |
no | string | "default" |
For multi-site setups |
- Finds navigation via
Nav::findByHandle()→404 resource_not_found - Always send the complete tree — partial patches are not supported
term.upsert
Creates or updates a taxonomy term.
{ "tool": "term.upsert", "arguments": { "taxonomy": "tags", "slug": "laravel", "site": "default", "data": { "title": "Laravel" } }}
| Argument | Required | Type | Default | Notes |
|---|---|---|---|---|
taxonomy |
yes | string | Must be in allowed_taxonomies |
|
slug |
yes | string | Term identifier | |
data |
yes | object | Field values as key-value pairs | |
site |
no | string | "default" |
For multi-site setups |
- Finds taxonomy via
Taxonomy::findByHandle()→404 resource_not_found - Returns
status: "created"orstatus: "updated"
cache.clear
Clears a specific cache target. Confirmation-gated in production by default.
{ "tool": "cache.clear", "arguments": { "target": "stache" }}
| Argument | Required | Type | Allowed values |
|---|---|---|---|
target |
yes | string | application, static, stache, glide |
Each target maps to an Artisan command:
| Target | Artisan command |
|---|---|
application |
cache:clear |
static |
statamic:static:clear |
stache |
statamic:stache:clear |
glide |
statamic:glide:clear |
requiresConfirmation()checksconfig('ai_gateway.confirmation.tools.cache.clear')for the current environment- Must be in
allowed_cache_targets
Request / Response Contract
Request Envelope
Every request to POST /ai-gateway/execute uses the same envelope:
{ "tool": "entry.create", "arguments": { "collection": "pages", "slug": "test", "data": { "title": "Test" } }, "request_id": "optional-tracking-id", "idempotency_key": "optional-dedup-key", "confirmation_token": "optional-if-confirming"}
| Field | Required | Type | Description |
|---|---|---|---|
tool |
yes | string | Tool name to invoke |
arguments |
yes | object | Tool-specific arguments |
request_id |
no | string | Echoed back in meta.request_id |
idempotency_key |
no | string | Included in audit log for dedup tracking |
confirmation_token |
no | string | Required when confirming a gated operation |
Success Response
{ "ok": true, "tool": "entry.create", "result": { "status": "created", "target_type": "entry", "target": { "collection": "pages", "slug": "hello-world", "site": "default" } }, "meta": { "request_id": "your-tracking-id" }}
Error Response
{ "ok": false, "tool": "entry.create", "error": { "code": "validation_failed", "message": "The title field is required.", "details": { "data.title": ["The title field is required."] } }, "meta": { "request_id": "your-tracking-id" }}
Confirmation Required Response
{ "ok": false, "tool": "cache.clear", "error": { "code": "confirmation_required", "message": "This operation requires explicit confirmation in production." }, "confirmation": { "token": "base64-encoded-token", "expires_at": "2026-04-14T12:05:00+00:00", "operation_summary": { "tool": "cache.clear", "target": "static", "environment": "production" } }, "meta": {}}
Error Codes
| Code | HTTP | Meaning |
|---|---|---|
unauthorized |
401 | Missing or invalid bearer token |
forbidden |
403 | Target not in allowlist |
tool_not_found |
404 | Tool name not registered |
tool_disabled |
403 | Tool exists but is not enabled |
validation_failed |
422 | Bad request envelope or tool arguments |
resource_not_found |
404 | Collection, entry, global, etc. doesn't exist |
conflict |
409 | Entry already exists (entry.create only) |
rate_limited |
429 | Too many requests |
confirmation_required |
200 | Confirmation token issued, re-send to confirm |
execution_failed |
500 | Tool threw an unexpected error |
internal_error |
500 | Unhandled server error (production only) |
Exception Mapping
The ToolExecutionController catches all known exception types and maps them to response envelopes:
| Exception | HTTP | Error Code |
|---|---|---|
ToolNotFoundException |
404 | tool_not_found |
ToolDisabledException |
403 | tool_disabled |
ToolAuthorizationException |
403 | forbidden |
ToolValidationException |
422 | validation_failed |
\Throwable (catch-all) |
500 | execution_failed / internal_error |
No exceptions bubble up to Laravel's default exception handler for addon routes.
Capability Discovery
Call GET /ai-gateway/capabilities to see what's available:
{ "ok": true, "tool": "capabilities", "result": { "capabilities": { "entry.create": { "enabled": true, "target_type": "entry", "requires_confirmation": false }, "entry.update": { "enabled": true, "target_type": "entry", "requires_confirmation": false }, "entry.upsert": { "enabled": false, "target_type": "entry", "requires_confirmation": false }, "global.update": { "enabled": false, "target_type": "global", "requires_confirmation": false }, "navigation.update": { "enabled": false, "target_type": "navigation", "requires_confirmation": false }, "term.upsert": { "enabled": false, "target_type": "taxonomy", "requires_confirmation": false }, "cache.clear": { "enabled": true, "target_type": "cache", "requires_confirmation": true } } }, "meta": {}}
This endpoint reads all registered tools from the ToolRegistry, instantiates each handler, and returns its enabled status, target type, and whether it requires confirmation in the current environment.
Internal Components
GatewayTool Interface
Every tool implements Stokoe\AiGateway\Tools\Contracts\GatewayTool:
interface GatewayTool{ public function name(): string; // e.g. 'entry.create' public function targetType(): string; // e.g. 'entry', 'global', 'cache' public function validationRules(): array; // Laravel validation rules public function resolveTarget(array $arguments): ?string; // Extract target for authorization public function execute(array $arguments): ToolResponse; // Perform the operation public function requiresConfirmation(string $environment): bool;}
ToolRegistry
Maps tool names to handler classes. Resolves tools via the Laravel container and checks enabled status from config:
resolve(string $name)→ returnsGatewayToolinstance, throwsToolNotFoundExceptionorToolDisabledExceptionisEnabled(string $name)→ checksconfig("ai_gateway.tools.{$name}")all()→ returns metadata for all registered tools (used by capabilities endpoint)
ToolPolicy
Centralized authorization:
toolEnabled(string $toolName)→ checks configtargetAllowed(string $targetType, string $target)→ checks the corresponding allowlistdeniedFields(string $targetType, ?string $target)→ returns the deny list for field filtering
FieldFilter
Strips denied fields from data payloads:
$filter->filter(['title' => 'Hello', 'author' => 'AI'], ['author']);// Returns: ['title' => 'Hello']
Uses array_diff_key() for efficient filtering.
ToolResponse
Value object for consistent response building with three static factories:
ToolResponse::success(string $tool, array $result, array $meta = [])ToolResponse::error(string $tool, string $code, string $message, int $httpStatus, array $meta = [], ?array $details = null)ToolResponse::confirmationRequired(string $tool, string $token, string $expiresAt, array $operationSummary, array $meta = [])
All produce a JsonResponse via toJsonResponse() with the correct envelope structure.
Testing
The addon has 81 tests with 11,500+ assertions across three categories:
Property-Based Tests (Eris)
11 correctness properties validated with 100+ random iterations each:
- Token comparison correctness —
hash_equalsreturns true iff tokens match - Request envelope validation — accepts valid envelopes, rejects malformed ones
- Request ID round-trip —
request_idappears unchanged in response meta - Tool registry resolution — correct handler for registered+enabled, exceptions for others
- Allowlist enforcement — targets in allowlist pass, all others denied
- Field filtering — preserves allowed fields, removes denied fields
- Tool argument validation — required fields, correct types, no unknown keys
- Confirmation token round-trip — generate then validate succeeds immediately
- Confirmation token binding — token for one (tool, args) pair fails for another
- Response envelope consistency — success/error/confirmation all have correct structure
- Audit log completeness and safety — required fields present, sensitive data absent
Unit Tests
- Addon disabled → 404 on both endpoints
- Addon enabled → routes registered (401 without token, not 404)
Integration Tests
Full HTTP pipeline tests covering:
- Authentication (missing/invalid/valid tokens, capabilities auth)
- Rate limiting (within limit, exceeding limit, separate buckets)
- Entry tools (create, duplicate conflict, update, upsert create/update paths, disallowed collection, nonexistent collection, request_id round-trip)
- Global tools (update existing, nonexistent global)
- Navigation tools (update existing, nonexistent navigation)
- Term tools (upsert create, upsert update, nonexistent taxonomy)
- Cache tools (valid target, invalid target, disallowed target, confirmation flow, confirmation with valid token)
- Capabilities endpoint (structure, tool listing, enabled status, target types)
- Audit logging (succeeded, failed, rejected events)
Running Tests
cd addons/stokoe/ai-gatewayvendor/bin/phpunit
Extending with Custom Tools
To add a new tool:
- Create a class implementing
GatewayToolinsrc/Tools/ - Define
name(),targetType(),validationRules(),resolveTarget(),execute(),requiresConfirmation() - Register it in
ToolRegistry::$tools - Add a config entry in
ai_gateway.tools - Add the corresponding allowlist config if needed
The tool automatically gets the full pipeline: authentication, rate limiting, envelope validation, authorization, field filtering, confirmation flow, audit logging.
When Should You Use This?
This addon is a great fit if you want to:
- Integrate agentic content management into a Statamic project
- Automate content workflows safely
- Allow AI-assisted updates without giving away full control
- Build custom AI-powered tooling on top of Statamic
- Maintain strong operational and security boundaries
When This Might Not Be For You
This addon is intentionally restrictive. It may not be the right fit if you want:
- Unrestricted programmatic control over your app
- Direct execution of arbitrary commands
- A large, exploratory tool surface for development experimentation
- AI acting with full system-level access
Minimal Setup Example
For a site that needs AI-managed content in pages and projects, with cache clearing:
.env:
AI_GATEWAY_ENABLED=trueAI_GATEWAY_TOKEN=sk_live_a1b2c3d4e5f6g7h8i9j0 AI_GATEWAY_TOOL_ENTRY_UPSERT=trueAI_GATEWAY_TOOL_CACHE_CLEAR=true
config/ai_gateway.php (published):
'allowed_collections' => ['pages', 'projects'],'allowed_cache_targets' => ['stache', 'static'], 'denied_fields' => [ 'entry' => ['author', 'blueprint'],],
Two tools, two collections, two cache targets, two denied fields. Everything else stays locked down.
Agent Setup Guide
This section walks through connecting an AI agent (such as OpenClaw) to one or more Statamic sites running the AI Gateway addon. The assumption is that you're managing multiple websites and want a single agent to operate across all of them.
1. Install the Skill
Install the AI Gateway skill from ClawHub so your agent knows how to interact with the endpoint:
clawhub install statamic-ai-gateway
The skill teaches your agent the request format, available tools, error handling, and confirmation flow. Without it, the agent won't know how to structure requests.
2. Prepare Each Site
On every Statamic site you want the agent to manage, you need three things: the addon enabled, a unique token, and the tools/targets configured.
Generate a unique, strong token for each site. Don't reuse tokens across sites — if one is compromised, you only need to rotate that one.
# Generate a random token (run once per site)openssl rand -hex 32
Add to each site's .env:
AI_GATEWAY_ENABLED=trueAI_GATEWAY_TOKEN=<the-token-you-generated>
Then publish and configure the allowlists for that site. Each site will likely have different collections, globals, and navigations — configure only what that specific site needs:
php artisan vendor:publish --tag=ai-gateway-config
3. Configure the Agent
Your agent needs to know about each site it manages. Set up a site registry — the exact format depends on your agent platform, but the information needed per site is:
| Field | Example | Notes |
|---|---|---|
| Name | marketing-site |
Human-readable label |
| Base URL | https://marketing.example.com |
The site's public URL |
| Endpoint | https://marketing.example.com/ai-gateway/execute |
Always {base_url}/ai-gateway/execute |
| Token | a1b2c3d4e5f6... |
The AI_GATEWAY_TOKEN for this site |
For example, if you manage three sites:
┌─────────────────────┬──────────────────────────────────────────┬──────────────┐│ Site │ Endpoint │ Token │├─────────────────────┼──────────────────────────────────────────┼──────────────┤│ Marketing site │ https://marketing.example.com/ai-gateway │ token-aaa... ││ Documentation site │ https://docs.example.com/ai-gateway │ token-bbb... ││ Client portal │ https://portal.example.com/ai-gateway │ token-ccc... │└─────────────────────┴──────────────────────────────────────────┴──────────────┘
Each site is independent — its own token, its own allowlists, its own rate limits. The agent selects the right endpoint and token based on which site it's operating on.
4. Discover Capabilities Per Site
Before performing operations on a site, the agent should call the capabilities endpoint to learn what's available:
GET https://marketing.example.com/ai-gateway/capabilitiesAuthorization: Bearer token-aaa...
This returns which tools are enabled, what target types they operate on, and whether any require confirmation. The agent should cache this per site and refresh periodically — capabilities can change when a site operator updates their config.
Different sites will have different capabilities. The marketing site might allow entry.upsert on pages and projects, while the docs site only allows entry.upsert on articles. The agent should respect these boundaries and not assume one site's config applies to another.
5. Multi-Site Operation Tips
Use unique request_id values. Include the site name in your request IDs (e.g. marketing-site:req_01abc) so audit logs are easy to trace across sites.
Handle errors per site. A 403 forbidden on one site doesn't mean the same target is forbidden on another. Always check the specific error and site context.
Rate limits are per site. Each site enforces its own rate limits independently. Hitting the limit on the marketing site doesn't affect your quota on the docs site.
Prefer entry.upsert over entry.create. When operating across multiple sites, upsert is more resilient — you don't need to track whether an entry already exists on each site.
Rotate tokens independently. If you need to rotate a token, update the .env on that specific site and the corresponding entry in your agent's site registry. Other sites are unaffected.
Test in staging first. Each site can have its own staging environment with the gateway enabled. Use separate tokens for staging vs production. The confirmation flow only activates in production by default, so staging operations execute immediately.
6. Verifying the Connection
Once a site is configured and the agent has its credentials, verify the connection:
# Check capabilities (should return 200 with tool list)curl -s -H "Authorization: Bearer <token>" \ https://your-site.com/ai-gateway/capabilities | jq . # Test a simple operation (if entry.upsert is enabled)curl -s -X POST \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{"tool":"entry.upsert","arguments":{"collection":"pages","slug":"test","data":{"title":"Connection Test"}},"request_id":"setup-test"}' \ https://your-site.com/ai-gateway/execute | jq .
If you get 401, check the token. If you get 404, the addon isn't enabled. If you get 403, the tool or target isn't in the allowlist. The error codes are designed to tell you exactly what's wrong.
Future Direction
The architecture is designed to grow safely over time. Potential future capabilities include:
- Controlled execution of whitelisted Artisan commands
- Read/query tools for content inspection
- Dry-run and "explain" modes for safer planning
- More advanced audit and observability features
- Deeper integration with custom application logic
All while maintaining the same core principle:
Controlled, explicit, and safe interaction between AI and your application.