Connect your AI models to Plone.
collective.ai 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 [email protected]:collective/collective-ai.git cd collective-ai
-
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.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.
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 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.
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.ai.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.ai. 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. 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"], "protect_with_permission": false, "permissions": [] }, { "model": "llava", "capabilities": ["vision"], "protect_with_permission": true, "permissions": ["Modify portal content"] } ] }, { "url": "https://api.openai.com", "api_key": "sk-…", "models": [] } ]