Configuration
MarkdownAI configuration
AI behaviour is configured in config/ai.php and consumed by laravel/ai plus Externa’s AppAssistant agent. Defaults target a local OpenAI-compatible gateway (LM Studio, vLLM, etc.).
Related
Env reference: Environment variables. HTTP routes: AI API. Product overview: AI assistant.
Default provider
| Config key | Env | Default |
|---|---|---|
default | AI_DEFAULT_PROVIDER | local |
default_for_images | (hard-coded) | gemini |
default_for_audio | (hard-coded) | openai |
default_for_transcription | (hard-coded) | openai |
default_for_embeddings | (hard-coded) | openai |
default_for_reranking | (hard-coded) | cohere |
AppAssistant is annotated with #[Provider('local')], so chat uses the local provider entry unless you change that attribute / default.
AI_DEFAULT_PROVIDER=local
Local provider (LOCAL_AI_*)
Under providers.local:
| Setting | Env | Default |
|---|---|---|
| Driver | (fixed) | openai (OpenAI-compatible API shape) |
| URL | LOCAL_AI_URL | http://127.0.0.1:1234/v1 |
| API key | LOCAL_AI_API_KEY | empty string |
| Default text model | LOCAL_AI_MODEL | config default local-model; .env.example sets openai/gpt-oss-20b |
| Translate model | LOCAL_AI_TRANSLATE_MODEL | (empty — falls back to LOCAL_AI_MODEL) |
LOCAL_AI_URL=http://127.0.0.1:1234/v1
LOCAL_AI_API_KEY=
LOCAL_AI_MODEL=openai/gpt-oss-20b
# Optional: faster non-thinking model for POST /ai/translate-fields (avoids nginx 504)
# LOCAL_AI_TRANSLATE_MODEL=phi-4-mini-instruct
# LOCAL_AI_REASONING_EFFORT=
# LOCAL_AI_CONTEXT_WINDOW=131072
LOCAL_AI_TRANSLATE_MODEL is an optional faster non-thinking model for AI field translation (POST /ai/translate-fields), so the blocking request does not hit an nginx 504. Example: phi-4-mini-instruct. See Translation workspace — AI translate.
Tool calling
Prefer a model that supports tool calling reliably. Tiny models often hallucinate successful tool results without calling tools — Externa’s instructions tell the agent never to invent success, but a weak model still wastes time.
AppAssistant sets a 300s HTTP timeout and a high maxSteps() budget (field-type count + 16) for long tool loops.
Local gateway / Herd nginx
LM Studio may emit response.reasoning_text.* SSE events; Externa remaps them to OpenAI-shaped response.reasoning_summary_text.* so laravel/ai keeps the stream alive. If the browser still shows a mid-stream “network error”, raise nginx fastcgi_read_timeout (Herd default is often 60s) to at least 600s while the model thinks without emitting tokens.
Cloud / other providers
config/ai.php registers many providers. Set the matching env keys when you switch AI_DEFAULT_PROVIDER or call a named provider:
| Provider key | Typical env vars |
|---|---|
anthropic | ANTHROPIC_API_KEY, ANTHROPIC_URL |
azure | AZURE_OPENAI_API_KEY, AZURE_OPENAI_URL, AZURE_OPENAI_API_VERSION, deployment names |
bedrock | AWS_BEDROCK_REGION, AWS_BEARER_TOKEN_BEDROCK, AWS credentials / AWS_USE_DEFAULT_CREDENTIALS |
cohere | COHERE_API_KEY |
deepseek | DEEPSEEK_API_KEY |
eleven | ELEVENLABS_API_KEY |
gemini | GEMINI_API_KEY, GEMINI_URL |
groq | GROQ_API_KEY |
jina | JINA_API_KEY |
mistral | MISTRAL_API_KEY |
ollama | OLLAMA_API_KEY, OLLAMA_URL (default http://localhost:11434) |
openai | OPENAI_API_KEY, OPENAI_URL |
openrouter | OPENROUTER_API_KEY |
voyageai | VOYAGEAI_API_KEY |
xai | XAI_API_KEY |
These are not all listed in .env.example; they are read when present.
Assistant provider, reasoning and context window (1.4.0)
The in-app Assistant (AppAssistant) runs on config('ai.assistant.provider'), set by AI_ASSISTANT_PROVIDER (default local). It can differ from AI_DEFAULT_PROVIDER.
Each provider can set a reasoning effort (low, medium or high) and a context window. The Assistant turns the effort into the provider's own option:
| Provider | Env var | Default | Sent as |
|---|---|---|---|
openai (and Azure) | OPENAI_REASONING_EFFORT | medium | reasoning.effort with an automatic reasoning summary |
anthropic | ANTHROPIC_REASONING_EFFORT | medium | extended thinking with 2048 / 8192 / 24576 budget tokens |
gemini | GEMINI_REASONING_EFFORT | medium | thinking_level with thought summaries |
local | LOCAL_AI_REASONING_EFFORT | (unset = none) | OpenAI-style reasoning.effort, only when set |
Any other value, or an empty one, sends no reasoning option, so the model keeps its own behaviour. Higher effort means slower and more expensive answers; the reasoning the model returns is shown, collapsed, above the answer — AI chat — What an answer shows.
OPENAI_CONTEXT_WINDOW (400000), ANTHROPIC_CONTEXT_WINDOW (200000), GEMINI_CONTEXT_WINDOW (1048576) and LOCAL_AI_CONTEXT_WINDOW (131072) set the context size in tokens that the Assistant shows next to each answer's token usage. They are display only and do not cap the request; set the local one to your model's real context length.
AI_ASSISTANT_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_REASONING_EFFORT=low
Remote import hosts
'remote_import_hosts' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('AI_REMOTE_IMPORT_HOSTS', '')),
))),
| Env | Default | Meaning |
|---|---|---|
AI_REMOTE_IMPORT_HOSTS | empty | Comma-separated host allowlist for ImportRemoteJson / related remote fetches. Empty → no remote hosts allowed. |
AI_REMOTE_IMPORT_HOSTS=api.example.com,data.partner.test
Security
Never leave remote import open to arbitrary hosts in production. Pair allowlists with short-lived Bearer tokens when the remote API requires auth.
Webhook HMAC secret
| Config | Env | Purpose |
|---|---|---|
webhook_token | AI_WEBHOOK_TOKEN | HMAC-SHA256 key for POST /ai/webhooks/collection-import (not a Bearer) |
webhook_token_previous | AI_WEBHOOK_TOKEN_PREVIOUS | Previous key during rotation grace (optional) |
webhook_token_previous_expires_at | AI_WEBHOOK_TOKEN_PREVIOUS_EXPIRES_AT | ISO 8601 end of grace window |
Clients must send X-AI-Webhook-Signature: sha256=<hex> where the digest is HMAC-SHA256 over the raw request body only (from 1.2.2, #214). Empty AI_WEBHOOK_TOKEN → 401 in every environment (1.3.0+; no local unsigned bypass); mismatch → 401. Bearer / X-AI-Webhook-Token alone is not enough. Rotate with ai:rotate-webhook-token — AI API.
CSRF stays excepted for this path (no session). Details + PHP snippet: AI API.
AI_WEBHOOK_TOKEN=generate-a-long-random-secret
Daily prompt limit
| Config | Env | Default |
|---|---|---|
daily_prompt_limit | AI_DAILY_PROMPT_LIMIT | 0 |
0 means unlimited. When set to a positive integer, AiChatController enforces a per-user daily cap.
AI_DAILY_PROMPT_LIMIT=0
Embeddings
| Config | Env | Default | Role |
|---|---|---|---|
embeddings.enabled | AI_EMBEDDINGS_ENABLED | false | When true, SearchSimilarCollectionItems uses stored vectors (cosine over JSON float arrays; pgvector later). Item saves queue GenerateCollectionItemEmbeddingJob. Without stored vectors / on provider failure, text fallback (semantic-lite) still runs. |
AI_EMBEDDINGS_ENABLED=false
Embedding caching block (separate from the feature flag):
'caching' => [
'embeddings' => [
'cache' => false,
'store' => env('CACHE_STORE', 'database'),
],
],
MCP server
Optional first-party MCP endpoint at /mcp (externa-core 1.4.0). Full guide: MCP server.
| Config | Env | Default | Role |
|---|---|---|---|
mcp.enabled | AI_MCP_ENABLED | false | /mcp returns 404 while false |
mcp.write_enabled | AI_MCP_WRITE_ENABLED | false | Register MCP write tools (still need mcp:write, the write gate, approval) |
mcp.rate_limit_per_minute | AI_MCP_RATE_LIMIT_PER_MINUTE | 60 | mcp limiter; a key's own rate_limit_per_minute wins when set |
AI_MCP_ENABLED=false
AI_MCP_WRITE_ENABLED=false
AI_MCP_RATE_LIMIT_PER_MINUTE=60
Tool registry and write gate
Shared by chat and MCP (externa-core 1.4.0). Write and destructive tools are denied by default. Full guide: AI tool approvals & audit.
| Config | Env | Default | Role |
|---|---|---|---|
tools.enabled | AI_TOOLS_ENABLED | true | false disables every AI tool |
tools.write_enabled | AI_WRITE_TOOLS (fallback AI_TOOLS_WRITE_ENABLED) | false | Kill switch for mutating tools |
tools.write_allow | AI_TOOLS_WRITE_ALLOW | empty | CSV of mutating tool class names (merged with Settings → AI write tools) |
tools.allow | AI_TOOLS_ALLOW | empty | When set, only these tool names are available |
tools.deny | AI_TOOLS_DENY | empty | Tool names that are never available |
tools.approval_ttl_minutes | AI_TOOLS_APPROVAL_TTL | 15 | Lifetime of MCP pending approvals |
tools.audit_retention_days | AI_TOOLS_AUDIT_RETENTION_DAYS | 90 | ai:prune-tool-audit retention |
tools.redact_keys | AI_TOOLS_REDACT_KEYS | built-in list | Argument keys redacted in the audit log and approval inbox |
AI_TOOLS_ENABLED=true
AI_WRITE_TOOLS=false
AI_TOOLS_WRITE_ALLOW=
AI_TOOLS_APPROVAL_TTL=15
AI_TOOLS_AUDIT_RETENTION_DAYS=90
AI_TOOLS_ALLOW=
AI_TOOLS_DENY=
Conversations tables
'conversations' => [
'connection' => env('DB_CONNECTION'),
'tables' => [
'conversations' => 'agent_conversations',
'messages' => 'agent_conversation_messages',
],
'generate_title' => false, // titles from first prompt via Str::limit
],
| Setting | Meaning |
|---|---|
| Tables | Laravel AI conversation persistence uses agent_conversations and agent_conversation_messages on the default DB connection. |
generate_title | false — Externa skips an extra LM call for titles; the first prompt is truncated instead. |
HTTP CRUD for conversations is under /ai/conversations/* (session auth + can-use-ai). See AI API.
Attachments cleanup
Expired chat attachments are purged by ai:cleanup-attachments (scheduled daily). Configure retention via attachment expires_at in application code; the command deletes expired AiChatAttachment rows and their files.
Minimal local AI .env
AI_DEFAULT_PROVIDER=local
LOCAL_AI_URL=http://127.0.0.1:1234/v1
LOCAL_AI_API_KEY=
LOCAL_AI_MODEL=openai/gpt-oss-20b
# LOCAL_AI_TRANSLATE_MODEL=phi-4-mini-instruct
AI_REMOTE_IMPORT_HOSTS=
AI_WEBHOOK_TOKEN=
AI_DAILY_PROMPT_LIMIT=0
AI_EMBEDDINGS_ENABLED=false
AI_MCP_ENABLED=false
# AI_WRITE_TOOLS=false