Connect your AI models to Plone.
collective.aisettings lets a Plone site talk to one or more OpenAI-compatible Large
Language Model services (Ollama, Lemonade, OpenAI, vLLM, etc.) from both
backend Python code and the Volto frontend, with per-model capability
declaration and permission gating.
- A registry-backed control panel to declare AI connections and the models hosted on each β usable from both the classic Plone UI and Volto.
- A reusable
IAIServiceutility so any Plone addon, view, subscriber, or block can performchat,think,analyze_image,embed, ortool_calloperations against the configured models. - An asynchronous
@aiREST endpoint that enqueues calls on a worker thread so long-running model invocations don't hit proxy/LB request timeouts, with a polling endpoint@ai-task/<id>for the result. - Capability-based resolution (
completion,embedding,vision,tools,thinking) so callers ask for what they need rather than for a specific model name. Callers can still pin a specific model name when they need to. - Per-model permission gating with OR semantics over any number of Plone permissions, so e.g. expensive vision models can be restricted to Editors while text completion is available to everyone.
- Generic passthrough connections β declare an endpoint with no pinned models and let callers ask for any model name the upstream service hosts.
- An operating system that runs all the requirements mentioned.
- uv
- nvm
- Node.js and pnpm 24
- Make
- Git
- Docker (optional)
- An OpenAI-compatible AI service reachable from the Plone backend. The
default development assumption is a local Ollama
install at
http://localhost:11434.
-
Clone this repository, then change your working directory.
git clone git@github.com:collective/collective-ai-settings.git cd collective-ai-settings -
Install this code base.
make install
-
Create a new Plone site on your first run.
make backend-create-site
-
Start the backend at http://localhost:8080/.
make backend-start
-
In a new shell session, start the frontend at http://localhost:3000/.
make frontend-start
Voila! Your Plone site should be live and kicking π
Deploy a local Docker Compose environment that includes the following.
- Docker images for Backend and Frontend πΌοΈ
- A stack with a Traefik router and a PostgreSQL database ποΈ
- Accessible at http://collective-ai-settings.localhost π
Run the following commands in a shell session.
make stack-create-site
make stack-startAfter installing the addon on a Plone site, the AI Settings control panel is registered under Site Setup β General. It supports both UIs:
- Volto:
http://localhost:3000/controlpanel/ai-settings - Classic Plone:
http://localhost:8080/Plone/@@ai-settings
Both UIs render the same data structure (a single JSONField in the registry) with a feature-equivalent rich editor.
The control panel stores a list of connections. Each connection is one remote AI service endpoint, and can optionally pin one or more models that live behind that endpoint:
A connection with an empty models list is a generic passthrough: it
won't be picked by capability-based resolution, but it can serve any model
name that an @ai caller asks for explicitly.
For deployments you often want to inject the endpoint, API key and models at
deploy time rather than clicking them into the ZODB β to keep secrets out of
the database and make the configuration reproducible. Set the environment
variable COLLECTIVE_AISETTINGS_CONNECTIONS to the absolute path of a
JSON file holding the same array of connections the control panel edits:
[
{
"url": "https://api.openai.com",
"api_key_env": "OPENAI_API_KEY", // read from the environment at load time
"models": [
{ "model": "gpt-4o", "capabilities": ["completion", "vision", "tools"] },
{ "model": "text-embedding-3-small", "capabilities": ["embedding"] }
]
},
{
"url": "http://localhost:11434", // no key needed
"models": [] // generic passthrough
}
]Notes:
- Same schema as the control panel, plus one extra optional field per
connection:
api_key_envnames an environment variable to read the key from, so the secret need not live in the file. When set and the variable is present it wins over any inlineapi_key; if the variable is unset it falls back to the inlineapi_key(and logs a warning). - File-first precedence. File connections are merged before the registry ones, so on any overlap (same capability, or an explicitly named model) the file wins; registry connections still extend the set for capabilities/model names the file does not cover.
- Live reload. The file is re-read when its modification time changes β no restart needed β and otherwise cached.
- Fail-safe. A missing, unreadable, malformed or schema-invalid file is logged as an error and ignored; the site keeps running on the registry-configured connections.
- Control-panel confirmation. Whenever the environment variable is set
and the file loads and validates without errors, both the classic and
Volto control panels show the file-provided connections read-only and
greyed out at the top (with a "Managed by environment configuration"
notice), as confirmation that the file was found and parsed correctly. The
panel below stays fully editable, so you can still add your own connections
through the web UI β they are stored in the registry and merged after the
file ones at runtime. API keys are never exposed to the browser: a
connection using
api_key_envshows only the variable name (the value is never read), and an inlineapi_keyis shown as a masked "set" indicator. If the variable is unset β or the file fails to load β no read-only section is shown and the panel behaves normally.
When a caller asks for an AI operation, the addon walks the connections in declared order and picks the first match:
- Caller passes
model=X(an explicit model name):- The first pinned model anywhere with
model == Xβ that connection + that model definition. - Otherwise the first generic-passthrough connection (empty
models) β uses its URL/api key withXas the model name to send. - Otherwise fail.
- The first pinned model anywhere with
- Caller passes no model, just a capability:
- The first pinned model whose
capabilitieslist contains the requested capability β use it. - Otherwise fail. Generic-passthrough connections are skipped here because they declare no capability metadata.
- The first pinned model whose
The ordering of connections, and the ordering of models within a connection, both matter. The UI supports drag-and-drop reordering at both scopes.
Each pinned model can advertise zero or more capabilities. The vocabulary
mirrors the strings returned by Ollama's /api/show
endpoint,
so the widget can auto-detect capabilities by querying the service:
| Token | Description |
|---|---|
completion |
Chat / text completion |
embedding |
Text embeddings (/v1/embeddings) |
vision |
Image understanding |
tools |
Function calling / tool use |
thinking |
Reasoning / chain-of-thought models |
Each pinned model can opt in to two independent gates:
- Only for authenticated β when enabled, the call is denied for anonymous callers; any logged-in user is allowed. No specific permission is required.
- Protect with permission β when enabled, the call is only allowed if the
current user holds at least one of the listed Plone permission titles
(e.g.
View,Modify portal content,Manage portal) on the call's context. The widget surfaces checkboxes for the three common permissions and a free-text + add-button for custom ones, with selected entries displayed as removable chips.
When both are enabled they combine with AND semantics: the caller must be authenticated and hold one of the listed permissions.
Generic passthrough connections cannot be gated per-model (they have no per-model definitions).
Any Plone addon, browser view, event subscriber, or block can use the registered global utility:
from collective.aisettings.interfaces import IAIService
from zope.component import queryUtility
service = queryUtility(IAIService)
# Capability-based selection (uses the first configured model that
# advertises `completion`)
text = service.chat("Summarise this article: β¦")
# Pin a specific model by name
text = service.chat("Summarise β¦", model="llama3.1:70b")
# Vision model
description = service.analyze_image(
"Describe the image", "https://β¦/photo.jpg",
)
# Embeddings (single string in, single vector out)
vector = service.embed("Hello world")
# Reasoning model
answer = service.think("Walk me through this proof: β¦")
# Tool / function calling β returns the full assistant message dict
reply = service.tool_call(messages, tools)
# Permission-gated call: pass `context=` to scope the check
text = service.chat("β¦", context=self.context)When the resolved model has protect_with_permission on, the utility runs
the gate against context (defaulting to the portal root) and returns
None if denied, logging the denial.
See backend/README.md for the full Python API reference.
The asynchronous REST endpoint accepts the same operations:
POST /Plone/<path>/++api++/@ai
Content-Type: application/json
Accept: application/json
{
"capability": "chat", // chat | think | vision | embed | tools
"prompt": "Summarise β¦",
"model": "llama3.1:70b", // optional; resolution falls back to capability
"system": "You are a helpful editor." // optional system instruction
}The endpoint replies immediately with HTTP 202 and a task id:
{ "task_id": "1244133e-β¦", "status": "running" }The client then polls until the task is done:
GET /Plone/<path>/++api++/@ai-task/<task_id>{
"task_id": "1244133e-β¦",
"status": "done",
"started_at": 1779291357.7,
"finished_at": 1779291488.8,
"result": { "response": "β¦" }
}The endpoint is registered for any IDexterityContent (so the URL can be
rooted at the site or at any content item), with zope2.View as the
required permission. The permission gate on the matched model is checked
against the called context and returns HTTP 403 on denial.
Body shapes per capability:
| capability | required body fields | optional | result key |
|---|---|---|---|
chat |
prompt |
system |
response |
think |
prompt |
system |
response |
vision |
prompt, image |
β | response |
embed |
input (string or list) |
β | embedding |
tools |
messages (array), tools (array) |
β | response |
image may be either a URL the AI service can fetch or a data: URI.
See frontend/README.md for the Volto-specific integration.
This monorepo consists of the following distinct sections:
- backend/ β Plone addon
collective.aisettings. Houses the registry schema, control-panel form, classic z3c.form widget, IAIService utility, async REST endpoint, capabilities vocabulary, and permission helpers. See backend/README.md and backend/AGENTS.md. - frontend/ β Volto addon
volto-collective-ai-settings. Houses the custom control-panel widget (ModelsWidget) that renders the connection / model UI in Volto. See frontend/README.md and frontend/AGENTS.md. - devops/ β Docker stack, Ansible playbooks, cache settings.
- docs/ β Scaffold for end-user documentation.
For agents working on this codebase, start at AGENTS.md.
To check your code against quality standards, run the following shell command.
make checkTo format and rewrite the code base, ensuring it adheres to quality standards, run the following shell command.
make format| Section | Tool | Description | Configuration |
|---|---|---|---|
| backend | Ruff | Python code formatting, imports sorting | backend/pyproject.toml |
| backend | zpretty |
XML and ZCML formatting | -- |
| frontend | ESLint | Fixes most common frontend issues | frontend/.eslintrc.js |
| frontend | prettier | Format JS and TypeScript code | frontend/.prettierrc |
| frontend | Stylelint | Format styles (css, less, sass) | frontend/.stylelintrc |
Formatters can also be run within the backend or frontend folders.
make lint| Section | Tool | Description | Configuration |
|---|---|---|---|
| backend | Ruff | Checks code formatting, imports sorting | backend/pyproject.toml |
| backend | Pyroma | Checks Python package metadata | -- |
| backend | check-python-versions | Checks Python version information | -- |
| backend | zpretty |
Checks XML and ZCML formatting | -- |
| frontend | ESLint | Checks JS / TypeScript lint | frontend/.eslintrc.js |
| frontend | prettier | Check JS / TypeScript formatting | frontend/.prettierrc |
| frontend | Stylelint | Check styles (css, less, sass) formatting | frontend/.stylelintrc |
Linters can be run individually within the backend or frontend folders.
Generate translation files for Plone and Volto with ease:
make i18nGenerated using Cookieplone (2.0.0a2) and cookieplone-templates (b0189a8) on 2026-05-20 10:53:22.434266. A special thanks to all contributors and supporters!
[ { "url": "http://localhost:11434", "api_key": "", "models": [ { "model": "llama3.2", "capabilities": ["completion", "tools"], "only_for_authenticated": false, "protect_with_permission": false, "permissions": [] }, { "model": "llava", "capabilities": ["vision"], "only_for_authenticated": true, "protect_with_permission": true, "permissions": ["Modify portal content"] } ] }, { "url": "https://api.openai.com", "api_key": "sk-β¦", "models": [] } ]