Support Bot — Rigid Decision-Tree Chatbot
Implementation plan for an optional Chamilo core feature: a support chatbot driven by a
fixed (non-AI) decision tree, reachable from the existing chat dock, with an authoring UI,
a conversation review UI, escalation to the existing Ticket system (v1), and a roadmap for
branching/canvas/live-agent hand-off (v2) and REST + MCP-based authoring for AI agents (v3).
This document describes what to build, not how to write the code. No code in this
repository is changed by this document.
0. Naming and scope conventions used throughout this plan
- Feature name: Support Bot.
- Entity/class prefix:
SupportBot* (SupportBotTree, SupportBotNode, SupportBotOption,
SupportBotConversation, SupportBotStep).
- Lives in CoreBundle (not CourseBundle): the bot is platform/portal-wide, not
course-scoped — same placement as the Ticket* entities, not like AiTutorConversation
which is tied to a Course.
- Settings category:
ticket (per requirement — this feature is a sibling of the
existing ticket settings, since its escalation path is the ticket system).
- v1 = rigid tree only (fixed node → option → node edges, no conditions). Branching,
a visual canvas editor, and live-agent handoff are explicitly deferred to v2 (§14).
Exposing tree authoring as REST + MCP tools for AI agents is deferred to v3 (§15).
1. Feature flag (setting) — must land first
Everything else is built behind this flag. New settings in this codebase are added by
extending SettingsCurrentFixtures.php (§1.2) — that's the authoritative, single place a
setting's name/category/title/comment is defined, and it's what the requirement's "fixture"
reference means. TicketSettingsSchema.php (§1.1) additionally defines the default value and
the form field type for the admin Settings UI. A migration (§1.3) is still needed so existing
installations pick up the new row, since fixtures only run on fresh installs/tests — but that
migration reruns the existing generic fixtures-upsert logic rather than hardcoding the new
setting's name in custom SQL.
1.1 src/CoreBundle/Settings/TicketSettingsSchema.php
Add one boolean setting, following the exact pattern of the existing ticket_* booleans in
this file:
buildSettings(): add 'ticket_support_bot_enabled' => 'false' to the setDefaults() array.
buildForm(): add ->add('ticket_support_bot_enabled', YesNoType::class).
This is the only setting needed for v1. No JSON/textarea setting is required (unlike
ai_providers) because v1 has nothing to configure beyond on/off — which tree is "active"
is a property of the tree itself (status = published), not a global setting (see §3).
1.2 src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php
In getExistingSettings(), add one entry to the existing 'ticket' => [...] array (the one
starting at line ~1531):
[
'name' => 'ticket_support_bot_enabled',
'title' => 'Enable support chatbot',
'comment' => 'Show a decision-tree chatbot in the chat panel that can guide users to '
.'an answer or open a support ticket on their behalf.',
],
This entry in SettingsCurrentFixtures.php is the single source of truth for the
setting's name/category/title/comment — this is how every setting in this project is added,
and it's sufficient on its own for fresh installs and PHPUnit/Behat test databases (which are
seeded from fixtures directly).
1.3 Existing installations: settings-upsert migration
Fixtures only run on fresh installs and test databases — an upgrade of an existing site only
runs migrations, it never re-runs DataFixtures. This codebase already has a generic,
reusable mechanism for exactly this gap: Version20260721125301.php (its own description
calls it "a fresh re-run of Version20250926174000's fixtures-upsert logic") reads
SettingsCurrentFixtures::getExistingSettings() and getNewConfigurationSettings() directly
and, for every entry not yet present in the settings table, inserts it (or updates its
title/category/comment if the row already exists but drifted).
Because that logic reads the fixtures file itself rather than hardcoding variable names, no
bespoke INSERT INTO settings ... needs to be written for ticket_support_bot_enabled.
What's needed is: add the entry to SettingsCurrentFixtures.php (§1.2), then add one more
migration that reruns this same upsert logic — copy Version20260721125301.php's body into
a new class with a fresh timestamp and a description naming this feature (the same way that
file itself was a rerun of the one before it). It will pick up
ticket_support_bot_enabled automatically. This migration is separate from the one that
creates the new tables (§2.3) — schema changes and settings upserts are kept as separate,
single-purpose migrations throughout this codebase's history, and there's no reason to break
that convention here.
1.4 Runtime check
Nowhere in this feature should a new "feature access helper" class be introduced —
AiFeatureAccessHelper exists because AI features have a three-state mode
(true/false/plugin_defined) and per-course configurability. This feature is a plain
on/off platform toggle, so every consumer reads it the same one-line way the existing
ticket_allow_student_add setting is read in TicketListProvider:
'true' === $this->settingsManager->getSetting('ticket.ticket_support_bot_enabled')
A second, independent condition always applies alongside the setting: a published tree
must exist for the current AccessUrl (or a portal-agnostic one, accessUrl IS NULL —
same fallback convention as TicketProject/TicketStatus/TicketPriority). If the setting
is on but no tree is published, the feature stays invisible. This avoids a half-configured
feature appearing with nothing behind it.
2. Data model (v1 — rigid tree)
2.1 Entity-relationship sketch
AccessUrl 1───* SupportBotTree
│ 1
│
*
SupportBotNode ──* SupportBotOption ──1 SupportBotNode (target, goto_node)
│ │
│ └──1 TicketCategory (escalate_ticket)
│
* (via SupportBotStep.node)
│
SupportBotTree 1───* SupportBotConversation *───1 User
│ 1 │
│ └── 0..1 Ticket (resultingTicket)
*
SupportBotStep ──0..1 SupportBotOption (selectedOption)
2.2 Entities
SupportBotTree (table support_bot_tree)
| Field |
Type |
Notes |
| id |
int |
|
| title |
string(255) |
shown only in admin |
| description |
text, nullable |
admin-only notes |
| accessUrl |
AccessUrl, nullable |
same portal-scoping fallback as TicketProject |
| status |
string, enum: draft|published|archived |
only one published tree per accessUrl (enforced in the admin service, not a DB constraint — same style as other Chamilo enum-like string columns) |
| version |
int, default 1 |
incremented every time a tree is (re)published |
| entryNode |
SupportBotNode, nullable (FK) |
the node shown first; nullable until the first node is created |
| defaultTicketProject |
TicketProject, nullable (FK) |
used when an escalating option doesn't specify its own project |
| createdBy |
User (FK) |
|
| createdAt / updatedAt |
datetime |
|
SupportBotNode (table support_bot_node)
| Field |
Type |
Notes |
| id |
int |
|
| tree |
SupportBotTree (FK, cascade delete) |
|
| content |
text |
message shown to the user; plain text in v1, see §9 XSS note |
| sortOrder |
int |
editor list ordering only, irrelevant to traversal |
| createdAt / updatedAt |
datetime |
|
A node with zero SupportBotOption rows is implicitly a dead end and must be flagged
invalid by the admin save validation (§4.3) — every leaf must end via an option whose
actionType is end_conversation or escalate_ticket, never by simply having no options.
This keeps traversal logic in one place (options), not split between "nodes with no options"
and "options with terminal actions".
SupportBotOption (table support_bot_option)
| Field |
Type |
Notes |
| id |
int |
|
| node |
SupportBotNode (FK, cascade delete) |
the node this button belongs to |
| label |
string(255) |
button text |
| sortOrder |
int |
display order among sibling options |
| actionType |
string, enum: goto_node|open_link|escalate_ticket|end_conversation |
|
| targetNode |
SupportBotNode, nullable (FK) |
required when actionType = goto_node |
| targetUrl |
string(500), nullable |
required when actionType = open_link; validated, see §9 open-redirect note |
| ticketCategory |
TicketCategory, nullable (FK) |
optional override when actionType = escalate_ticket |
| closingMessage |
text, nullable |
optional override text when actionType = end_conversation |
SupportBotConversation (table support_bot_conversation)
| Field |
Type |
Notes |
| id |
int |
|
| tree |
SupportBotTree (FK) |
|
| treeVersion |
int |
snapshot of tree.version at start — so a tree edit mid-conversation doesn't retroactively change what's shown to an in-progress user, and so review (§7) always reflects what the user actually saw |
| user |
User (FK) |
who is chatting |
| accessUrl |
AccessUrl, nullable |
|
| currentNode |
SupportBotNode, nullable (FK) |
null once conversation has ended |
| outcome |
string, enum: in_progress|resolved|escalated|abandoned |
|
| resultingTicket |
Ticket, nullable (FK) |
set only when outcome = escalated |
| startedAt |
datetime |
|
| endedAt |
datetime, nullable |
|
SupportBotStep (table support_bot_step)
| Field |
Type |
Notes |
| id |
int |
|
| conversation |
SupportBotConversation (FK, cascade delete) |
|
| node |
SupportBotNode (FK) |
node that was shown to the user at this step |
| selectedOption |
SupportBotOption, nullable (FK) |
null on the last step of a conversation (nothing chosen yet, or conversation ended/abandoned) |
| createdAt |
datetime |
|
SupportBotStep is the append-only transcript log — structurally the same role as
AiTutorMessage, but storing "which node was shown / which option was picked" instead of
free-text role/content pairs, since the tree is rigid and there's nothing else to log.
2.3 Migration
One new file in src/CoreBundle/Migrations/Schema/V200/, in the same style as the existing
migrations there (Schema + $this->addSql(...), up()/down()):
- Create the 5 tables above with their FKs.
- Indexes:
(tree_id) on node; (node_id) on option; (tree_id, user_id) and
(access_url_id) on conversation (mirrors idx_ai_tutor_conv_user_course /
idx_ai_tutor_conv_course); (conversation_id, created_at) on step (mirrors
idx_ai_tutor_msg_conv_created).
This migration only touches the 5 new tables — it does not insert the
ticket_support_bot_enabled settings row; that's a separate settings-upsert migration
(§1.3).
3. Runtime engine (backend)
New service: src/CoreBundle/Service/SupportBot/SupportBotEngine.php (mirrors
src/CoreBundle/Service/Ticket/TicketWorkflowService.php's role: it is the single place that
enforces conversation rules, called by the runtime controller).
Responsibilities:
- Start a conversation — resolve the current
AccessUrl's published tree (fallback to
the portal-agnostic one, same lookup pattern as TicketListProvider::getProjects()); if
none, throw/return "unavailable". Create a SupportBotConversation row pointing at
tree.entryNode, outcome = in_progress, and a first SupportBotStep with
node = entryNode, selectedOption = null.
- Advance a conversation — given a
conversationId and a optionId:
- Verify the conversation belongs to the current user (ownership check, §9).
- Verify the option belongs to
conversation.currentNode (defends against a client
sending an option id that isn't actually offered at the current step — never trust the
client's idea of "what node it's on").
- Record a
SupportBotStep with selectedOption set.
- Branch on
option.actionType:
goto_node: set conversation.currentNode = option.targetNode, append a new step
for that node with selectedOption = null, return the new node + its options.
open_link: return the URL to the caller (client opens it in a new tab); the
conversation does not advance or end — the same node/options are returned again
so the user can still pick a different option afterwards.
end_conversation: set conversation.outcome = resolved, endedAt = now,
currentNode = null; return option.closingMessage (or a generic default).
escalate_ticket: create a Ticket (see §6), set
conversation.outcome = escalated, resultingTicket, endedAt = now,
currentNode = null; return a confirmation payload containing the new ticket's id/url.
- Abandon — if a user closes the chat dock or logs out mid-conversation, there is no
explicit "abandon" call; a scheduled cleanup (existing Chamilo cron mechanism, e.g. a new
console command run daily) marks any in_progress conversation with
startedAt < now - 24h as outcome = abandoned. This keeps the review screen (§7) from
showing stale "in progress" rows forever.
4. Admin authoring
4.1 API surface
Mirrors the Ticket module's split: #[ApiResource] + Provider DTOs for read-heavy list/
detail screens (like TicketList/TicketListProvider, TicketDetail), a plain
#[AsController] for mutations (like TicketAdminController).
| Concern |
Pattern |
New files |
| List trees (admin) |
ApiResource + Provider |
ApiResource/SupportBot/SupportBotTreeList.php, State/SupportBot/SupportBotTreeListProvider.php |
| Get one tree with all nodes/options (for the editor) |
ApiResource + Provider |
ApiResource/SupportBot/SupportBotTreeDetail.php, State/SupportBot/SupportBotTreeDetailProvider.php |
| List conversations for a tree (review) |
ApiResource + Provider |
ApiResource/SupportBot/SupportBotConversationList.php, State/SupportBot/SupportBotConversationListProvider.php |
| Get one conversation transcript |
ApiResource + Provider |
ApiResource/SupportBot/SupportBotConversationDetail.php, State/SupportBot/SupportBotConversationDetailProvider.php |
| Create/update/delete/publish/duplicate a tree (whole tree saved as one payload — nodes + options together, since the tree is edited as a unit in the tabular editor, not node-by-node) |
#[AsController], ROLE_ADMIN |
Controller/Api/SupportBotAdminController.php, Service/SupportBot/SupportBotAdminService.php |
| Import (JSON/CSV) / Export (JSON) |
same controller as above |
— |
All admin endpoints: #[IsGranted('ROLE_ADMIN')] at the controller level. This feature has
no "session admin" or delegated-editor concept in v1 — not because it's hard, but because
nothing today asks for partial delegation of tree editing; if that need shows up later it can
be added the same way TicketCategoryRelUser delegates ticket-category responsibility,
without changing anything else in this plan.
4.2 CSRF
Per project convention: SupportBotAdminService::CSRF_TOKEN_ID = 'support_bot_admin'.
SupportBotTreeListProvider and SupportBotTreeDetailProvider each return a csrfToken
field in their JSON payload (exactly like TicketList::$csrfToken); every mutating call in
SupportBotAdminController validates it with
$this->isCsrfTokenValid('support_bot_admin', $token) before touching anything.
4.3 Save/validation rules (enforced server-side in SupportBotAdminService, not just client-side)
- Every node except leaves must have ≥1 option.
- Every leaf must have ≥1 option whose
actionType is end_conversation or
escalate_ticket (a leaf silently having zero options is rejected — see §2.2).
entryNode must belong to the tree being saved.
- Every
targetNode referenced by a goto_node option must belong to the same tree
(prevents an admin payload from wiring a node into a foreign tree — see §9 mass-assignment
note).
- No orphan nodes: every node except
entryNode must be reachable by following at least one
option chain from entryNode (simple graph traversal in PHP; reject with the list of
unreachable node refs so the admin can fix them). This is cheap to check because v1 has no
cycles by construction (every edge points from a node to one created after/alongside it in
the editor — see §14.1 for what changes once branching allows loops).
- Publishing sets
status = published, increments version, and demotes any other
published tree on the same accessUrl to archived (only one active tree per portal).
4.4 Vue views
New directory assets/vue/views/supportbot/:
SupportBotTreeList.vue — BaseTable: title, status badge, version, portal scope, updated
date. Row actions: edit (secondary-text/pencil), duplicate (secondary-text), publish/
unpublish toggle, delete (danger-text/delete, behind useConfirmation), "View
conversations" link. Header actions: "+ Create" (success), "Import" (success, opens a
BaseDialog file-upload), "Export" per row (primary).
SupportBotTreeEdit.vue — the tabular node/option editor (see mockup below). Left: a
reorderable list of nodes (add/remove/reorder + "set as entry node"). Right: a form for the
selected node — content (BaseTextArea), and a repeatable list of options, each with
label (BaseInputText), actionType (BaseSelect), and the matching conditional field
(targetNode a BaseSelect of the tree's own nodes / targetUrl a BaseInputText /
ticketCategory a BaseSelect / closingMessage a BaseTextArea). One "Save tree" button
submits the whole node+option graph in a single request (matches §4.3 — validation needs
the whole graph at once anyway).
SupportBotConversationList.vue / SupportBotConversationDetail.vue — see §7.
Tabular editor mockup (v1 — no canvas, see §13.2 for the v2 alternative):
┌─ Nodes ──────────────┐ ┌─ Node: "n2 – Which device?" ─────────────────────────┐
│ ● n1 Welcome [entry]│ │ Content: │
│ n2 Which device? │ │ ┌───────────────────────────────────────────────┐ │
│ n3 Check network │ │ │ Does the video fail on all devices or just │ │
│ n4 Clear cache │ │ │ one? │ │
│ n5 Reset password │ │ └───────────────────────────────────────────────┘ │
│ [+ Add node] │ │ Options: │
└───────────────────────┘ │ ┌─────────────────────────────────────────────┐ │
│ │ Label: All devices → Go to node: n3 [x]│ │
│ ├─────────────────────────────────────────────┤ │
│ │ Label: One device → Go to node: n4 [x]│ │
│ └─────────────────────────────────────────────┘ │
│ [+ Add option] [Save tree] │
└───────────────────────────────────────────────────┘
5. Import / export format
Primary format: JSON. A CSV alternative is offered because non-technical support staff
often draft trees in a spreadsheet first — both import to the exact same validated model
(§4.3 rules apply identically regardless of source format).
5.1 JSON schema
{
"tree": {
"title": "Video playback & login help",
"description": "First-line support tree for common LMS issues",
"defaultTicketProject": "Support"
},
"nodes": [
{
"ref": "n1",
"content": "Hi! What do you need help with?",
"options": [
{ "label": "A video won't play", "action": "goto_node", "target": "n2" },
{ "label": "I can't log in", "action": "goto_node", "target": "n5" }
]
},
{
"ref": "n2",
"content": "Does the video fail on all devices or just one?",
"options": [
{ "label": "All devices", "action": "goto_node", "target": "n3" },
{ "label": "Just one device/browser", "action": "goto_node", "target": "n4" }
]
},
{
"ref": "n3",
"content": "Please check your internet connection and try our compatibility guide.",
"options": [
{ "label": "That fixed it, thanks!", "action": "end_conversation",
"closingMessage": "Glad we could help!" },
{ "label": "Still not working", "action": "escalate_ticket",
"ticketCategory": "Technical" }
]
}
],
"entryNode": "n1"
}
ref is an import-time-only string alias (so an option can reference a node defined further
down in the file, before it has a real database id). The importer resolves every target/
entryNode ref to a node id and discards the refs — they are never persisted.
5.2 CSV alternative
One row per option (a node with 2 options produces 2 rows sharing the same node_ref/
node_content):
| node_ref |
node_content |
option_label |
option_action |
option_target_ref |
option_ticket_category |
| n1 |
Hi! What do you need help with? |
A video won't play |
goto_node |
n2 |
|
| n1 |
Hi! What do you need help with? |
I can't log in |
goto_node |
n5 |
|
| n3 |
Please check your connection... |
That fixed it, thanks! |
end_conversation |
|
|
| n3 |
Please check your connection... |
Still not working |
escalate_ticket |
|
Technical |
5.3 Export
"Export" on SupportBotTreeList.vue downloads the tree in the JSON format above — this
doubles as a backup/versioning mechanism (an admin can keep exported trees in their own git
repo) and as the round-trip format for duplicating a tree across portals.
6. Escalation to a human agent (Ticket integration)
When an option's actionType = escalate_ticket fires (§3, step 2):
- Resolve the
TicketProject: option.ticketCategory?.project ?? tree.defaultTicketProject;
if neither resolves, fail loud (admin misconfiguration — this must be caught at
publish-time by §4.3, not silently swallowed at runtime).
- Build the
Ticket.message body from the conversation transcript: render every
SupportBotStep as "{node.content}\n→ {selectedOption.label}", joined in order. This
gives the human agent the full path the user took without them re-asking the same
qualifying questions.
- Create the
Ticket via the existing TicketWorkflowService (do not duplicate ticket
creation logic) with category = option.ticketCategory, project as resolved above,
insertUserId = conversation.user, subject = a generated string such as
"Support Bot: {tree.title}".
- Set
conversation.resultingTicket, outcome = escalated.
- Return to the client a confirmation node-like payload: closing text plus a router-link to
the new ticket ({ name: 'TicketDetail', params: { id } }), rendered as a BaseButton
inside the chat bubble.
No new ticket-side code is needed — this is pure composition on top of the existing Ticket
entities/service listed in §0.
7. Conversation logging & review
SupportBotConversationList.vue (admin, per tree) — BaseTable filterable by outcome,
date range, and free-text keyword (matched against step content via a LIKE, same style
as TicketListProvider::applyFilters()'s keyword search). Columns: user, started/ended
at, outcome badge (Active blue / Resolved green / Escalated gray with a ticket link /
Abandoned red — reusing the badge convention already in use elsewhere), tree version.
SupportBotConversationDetail.vue — read-only chat-transcript rendering of the ordered
SupportBotStep rows (bot bubble = node.content, "user" bubble = selectedOption.label),
plus a link to resultingTicket if escalated. No edit capability — this is an audit view,
not a place to alter history.
8. Vue: chat dock integration (assets/vue/components/chat/DockedChat.vue)
This file already has the exact shape needed, built for the AI Tutor peer
(const AI_PEER_ID = -1). Add a second synthetic peer:
const SUPPORT_BOT_PEER_ID = -2
- A quick-entry button next to the existing "AI Tutor" one (§ same block as
tutorCtx.enabled && !contactsHasAiTutor), gated by a new supportBotCtx.enabled flag
fetched the same way tutorCtx is — except it is not gated by inCourse: the bot is
platform-wide, so it must be visible from any page, not only inside a course.
- Message rendering change: today every bubble is either legacy contacts HTML or plain
text. Add a third bubble kind, "options", rendered as a stack of BaseButton elements (one
per current SupportBotOption), used only when activePeer.id === SUPPORT_BOT_PEER_ID.
When this peer is active, the free-text input box is hidden entirely — the tree is rigid,
there is nothing for the user to type (except in the escalate-confirmation state, which is
just a button, not text either).
- Sending: clicking an option button calls the runtime "advance conversation" endpoint
(§3) with { conversationId, optionId, _token }; the response (next node + its options, or
a closing/escalation payload) is appended to the transcript exactly like a new incoming
message today.
- Opening the bot for the first time calls "start conversation" (§3, step 1) and renders the
entry node.
9. Security checklist (per CLAUDE.md Rule 13 — OWASP)
| Concern |
Where it applies |
Mitigation |
| CSRF |
Every admin mutation (SupportBotAdminController: save tree, delete, publish, import) and every runtime mutation (SupportBotRuntimeController: start conversation, advance conversation) |
CsrfTokenManagerInterface::getToken('support_bot_admin' / 'support_bot_chat'), validated via isCsrfTokenValid(); token round-tripped in the JSON payload (there is no HTML <form> here, so the token travels as a JSON field returned by the GET/start call and echoed back on the next POST, same mechanism TicketList::$csrfToken already uses) |
| Broken access control (admin) |
All SupportBotAdminController routes, SupportBotTreeList/SupportBotConversationList providers |
#[IsGranted('ROLE_ADMIN')] at controller level — no session-admin exception needed since v1 has no delegated editors (§13 candidate) |
| Broken access control (ownership) |
SupportBotRuntimeController::advanceConversation |
Verify conversation.user === currentUser before any read/write; return 404 (not 403) on mismatch to avoid confirming the conversation id exists, same reasoning as the "per-user owned ApiResource" pattern in CLAUDE.md |
| SQL injection |
SupportBotTreeListProvider/SupportBotConversationListProvider filters and sorting |
Bound parameters throughout (setParameter); sort field allowlist map, never a raw client string into orderBy() — exact copy of TicketListProvider::applySorting()'s $sortMap pattern |
| XSS |
SupportBotNode.content rendering in DockedChat.vue and in the admin transcript view |
v1 stores/renders plain text only — Vue's {{ }} auto-escapes; no v-html for node content anywhere, unlike the legacy-contacts-HTML branch already in DockedChat.vue which is a separate, pre-existing code path. If rich text is ever wanted, it must be sanitized server-side before storage, not just escaped at render time |
| Open redirect |
SupportBotOption.targetUrl (open_link action) |
Validate on save: must be either a relative Chamilo path or an http(s):// absolute URL against an admin-configured allowlist of domains (reuse whatever mechanism, if any, already validates external links elsewhere — otherwise restrict to relative paths only for v1 and revisit); render as <a target="_blank" rel="noopener"> or open via window.open(), never assign to window.location.href from unsanitized data |
| Mass parameter manipulation |
SupportBotAdminController::saveTree (whole-graph payload) |
Every node/option id present in the incoming payload must be verified to belong to the tree being edited (node.tree_id === tree.id) before any write — an admin editing tree A must not be able to silently attach/detach nodes that belong to tree B; array of node ids cast with array_map('intval', ...) |
10. Vue routing & breadcrumbs
No new top-level Symfony route is needed. The only new pages are admin-only (the tree editor
and the conversation review — end users never leave the chat dock to use the bot itself), so
nesting everything under the existing /admin/{vueRouting} catch-all
(IndexController::index(), already registered) avoids adding a new entrypoint route and a
new Breadcrumb.vue whitelist entry — the /admin/* breadcrumb case documented in
Breadcrumb.vue already applies automatically.
Add to assets/vue/router/admin.js's children array:
{
name: "SupportBotTreeList",
path: "support-bot",
meta: { requiresAdmin: true, showBreadcrumb: true, breadcrumb: "Support bot" },
component: () => import("../views/supportbot/SupportBotTreeList.vue"),
},
{
name: "SupportBotTreeCreate",
path: "support-bot/create",
meta: { requiresAdmin: true, showBreadcrumb: true, breadcrumb: "Create tree" },
component: () => import("../views/supportbot/SupportBotTreeEdit.vue"),
},
{
name: "SupportBotTreeEdit",
path: "support-bot/:id(\\d+)/edit",
meta: { requiresAdmin: true, showBreadcrumb: true, breadcrumb: "Edit tree" },
component: () => import("../views/supportbot/SupportBotTreeEdit.vue"),
},
{
name: "SupportBotConversationList",
path: "support-bot/:id(\\d+)/conversations",
meta: { requiresAdmin: true, showBreadcrumb: true, breadcrumb: "Conversations" },
component: () => import("../views/supportbot/SupportBotConversationList.vue"),
},
{
name: "SupportBotConversationDetail",
path: "support-bot/:id(\\d+)/conversations/:conversationId(\\d+)",
meta: { requiresAdmin: true, showBreadcrumb: true, breadcrumb: "Conversation" },
component: () => import("../views/supportbot/SupportBotConversationDetail.vue"),
},
The generic Settings admin screen already exposes ticket_support_bot_enabled once §1 is
done — no bespoke settings page is needed.
11. Admin menu entry
Add one item to getItemsTracking() (or a more fitting section, e.g. next to the existing
item-ticket-system entry) in
src/CoreBundle/Controller/Admin/IndexBlocksController.php:
$items[] = [
'class' => 'item-support-bot',
'url' => '/admin/support-bot',
'label' => $this->translator->trans('Support bot'),
];
Gate its visibility with the same ticket_support_bot_enabled setting check (follow this
controller's existing pattern for conditionally-shown items) so the menu entry disappears
when the feature is off.
12. Testing plan
12.1 PHPUnit
SupportBotEngine: start conversation resolves the right tree (portal-specific over
portal-agnostic fallback); advancing with a foreign option id is rejected; each
actionType branch produces the expected conversation/step state; escalation creates a
real Ticket with the expected category/project and transcript text.
SupportBotAdminService: every §4.3 validation rule has a dedicated failing case (missing
entry node, cross-tree target, unreachable node, leaf without a terminal option); publish
demotes the previously published tree; import (JSON and CSV) produces an identical model
to hand-building the same tree via the admin service.
- Security regression: a non-owner calling "advance conversation" on someone else's
conversation id gets 404; a non-admin calling any SupportBotAdminController route gets
403; a request missing/with a wrong CSRF token is rejected on every mutating endpoint.
12.2 Behat (tests/behat/features/supportbot/, mirroring the view directory)
Per the mandatory rule in CLAUDE.md, every interaction needs coverage, and — since this
page is accessible only to admins for authoring/review but to every logged-in role for
using the bot — scenarios must run once per relevant role:
manage-trees.feature (admin only): create a tree with a couple of nodes/options,
edit it, publish it, delete it, import a JSON file, export a tree. Include a scenario that
a non-admin (student, teacher) cannot reach /admin/support-bot (redirected/denied).
use-chatbot.feature, run once as a student and once as a teacher: open the chat
dock, start the bot, walk a full path to a resolved end, and separately walk a path to
escalate_ticket and confirm a ticket appears in /tickets for that user.
review-conversations.feature (admin only): after a scenario like the above runs, the
admin can see the conversation in the list, filter it by outcome, and open its transcript.
Every form control in the new Vue views must carry a name attribute per the project rule,
so these steps can use I fill in "name" with "value" / I select "option" from "name".
Each feature file creates and tears down its own tree/settings state, leaving the database as
found (per the project's Behat self-containment rule) — in particular, restoring
ticket_support_bot_enabled to its prior value at the end.
13. Rollout notes
- Ships in core, not a plugin: the migration always runs, the tables always exist. With the
setting at its default (false) and no published tree, the feature is completely inert —
zero visible surface, negligible runtime cost (one settings lookup + one indexed query per
page that renders DockedChat.vue, cached the same way tutorCtx already is).
- No BC concerns: nothing existing is touched.
Ticket, TicketCategory, TicketProject
are only ever read by this feature, never modified in shape.
- Suggested build order (each step independently testable before moving to the next):
- Setting fixture entry + schema migration + settings-upsert migration (§1, §2.3) — land
first so PHPUnit/Behat DBs have the schema and the setting early.
- Entities + repositories, no UI yet.
SupportBotEngine + runtime controller, tested via PHPUnit only (no chat UI yet).
- Admin CRUD (
SupportBotAdminService/SupportBotAdminController) + SupportBotTreeList/
SupportBotTreeEdit Vue views — an admin can now author and publish a tree end-to-end.
DockedChat.vue integration — a user can now actually talk to the bot.
- Import/export.
- Conversation review screens.
- Behat coverage for all of the above (can be written incrementally alongside each step
rather than only at the end).
14. V2 roadmap (explicitly out of scope for v1)
These three items were identified while scoping v1 and are real, separately-shippable
features. Listed here so v1's data model choices (§2) don't have to be redesigned later —
each subsection notes what in v1 already accommodates it and what doesn't.
14.1 Branching (conditional tree traversal)
Problem v1 doesn't solve: a fixed button click is the only way to move through the
tree. There's no way to ask the user to type something (an order number, a course name)
and branch on it, and no way to branch on facts Chamilo already knows about the user
(role, enrollment, session membership) without making them click through a redundant
question.
Data model additions:
- New node type: an
input node — asks the user to type/pick a value (text, number, or a
small enum), stored as a named variable scoped to the conversation.
- New entity
SupportBotVariable (conversation-scoped key/value store): one row per
(conversation_id, name).
SupportBotOption gains a nullable conditionExpression column. Evaluated server-side
with Symfony's ExpressionLanguage component (sandboxed expression evaluation — never
eval()), against the conversation's variables plus a small set of built-in facts
(user.role, user.hasActiveSession, etc., resolved by the engine, not user-supplied).
Options are evaluated in sortOrder; the first whose condition is true (or that has no
condition at all, i.e. a "fallback/else" option) wins.
- This also changes §4.3's "no cycles" assumption: branching by definition allows a node to
be reachable through more than one path, and — if ever desired — loops (e.g. "ask again if
the input didn't validate"). The reachability/leaf-validation rules in §4.3 need
generalizing to a proper graph-reachability check (still cheap, just no longer "every edge
points forward").
Illustration — v1's fixed edges vs. v2's conditional edges on the same node:
v1 (rigid): v2 (branching):
[n1: "What's your role?" [n1: input node, stores answer as
— pick a button —] variable `role`]
├─ "I'm a teacher" → n2 ├─ if role == "teacher" → n2
└─ "I'm a student" → n3 ├─ if role == "student" → n3
└─ (no condition = fallback) → n4
14.2 Visual flowchart canvas editor
Problem v1 doesn't solve: the tabular editor (§4.4) is fast to use but doesn't show the
overall shape of a large tree — an admin authoring 40+ nodes may struggle to see at a
glance which branches are getting long or where a loop (once v2.1 allows them) closes.
Approach: add a second, optional front-end over the same data model — no new
entities beyond two presentational columns, positionX/positionY (float), added to
SupportBotNode. A drag-and-drop canvas library (e.g. Vue Flow) renders nodes as boxes and
options as directional connectors; dragging a connector from one node's option handle onto
another node updates that option's targetNode/conditionExpression exactly as the tabular
editor's dropdowns would. Both views stay available — some admins will prefer the tabular
list for fast linear edits, others the canvas for seeing structure; this is purely additive,
not a replacement.
Needed canvas behaviors: pan/zoom, add-node context menu, click-node opens the same
content/options mini-form used in the tabular editor (as a side panel or modal, not
reinvented), and live validation highlighting (e.g. a node with no incoming edge glows red as
"unreachable", a non-leaf with zero outgoing edges glows red as "dead end") reusing the exact
rules from §4.3/§14.1.
Illustration — same tree as the tabular mockup in §4.4, as a canvas would render it:
┌─────────────────┐ "video won't play" ┌───────────────────────┐
│ n1: Welcome │────────────────────────▶│ n2: Which device? │
│ │ └───────────┬───────────┘
│ │ "can't log in" │ "all devices"
│ │────────────┐ ▼
└──────────────────┘ │ ┌───────────────────────┐
▼ │ n3: Check connection │
┌───────────────────────┐ └───────────┬───────────┘
│ n5: Reset password │ │ "still broken"
└───────────────────────┘ ▼
┌───────────────────────┐
│ [escalate → Technical] │
└───────────────────────┘
14.3 Live agent hand-off (real-time human takeover)
Problem v1 doesn't solve: escalation in v1 is always asynchronous — it opens a Ticket
and the conversation ends there. There is no way for the user to be connected, in the same
chat session, to a human agent typing back in real time.
Approach: add a fifth actionType on SupportBotOption: live_handoff. Firing it does
not end the conversation the way escalate_ticket does — instead:
conversation.outcome gains a new value, awaiting_agent.
- A small "available agents" concept is needed: reuse the presence toggle
DockedChat.vue already shows in its header (userStatus/"Online"/"Offline") — any user
with ROLE_ADMIN (or a new, narrower ROLE_SUPPORT_AGENT if platforms want to delegate
this without full admin rights) who is marked online is "available".
- New admin/agent view,
SupportBotLiveQueue.vue — a list of conversations currently
awaiting_agent, each with a "Claim" button. Claiming is a single conditional UPDATE
(SET claimedByUserId = :agent WHERE id = :id AND claimedByUserId IS NULL) so two agents
racing to claim the same request can't both succeed — first write wins, matching
optimistic-concurrency conventions already used elsewhere in the codebase.
- Once claimed, no new transport is built: the user's active peer in
DockedChat.vue is
simply reassigned from SUPPORT_BOT_PEER_ID to the claiming agent's real user id, and the
existing human-to-human messenger flow (the same one ChatController/ChatRepository
already serve today) takes over untouched — the bot's job was only to route the
conversation to a person, not to relay messages between them.
- Fallback: if no agent claims the request within a configurable timeout, the client
prompts the user to fall back to escalate_ticket instead (v1's async path), so the
feature degrades gracefully when no one is online rather than leaving the user stuck.
Illustration — hand-off sequence:
User Bot engine Live queue Agent
│ clicks "Talk to a human" │ │
│ ──────────────────────────▶│ │
│ │ conversation.outcome = │
│ │ awaiting_agent │
│ │ ───────────────────────────────────▶│ appears in queue
│ │ │ clicks "Claim"
│ │◀───────────────────────────────────│
│ now chatting with a real person, via the existing messenger │
│◀════════════════════════════════════════════════════════════════▶│
│ │ │
│ (if nobody claims within timeout) │
│◀── "No agent available — open a ticket instead?" ──│ │
15. V3 roadmap — REST web services + MCP-based tree authoring for AI agents
Goal: let an administrator build/edit a Support Bot tree by directing an AI agent (e.g.
an MCP-compatible client such as Claude) instead of clicking through
SupportBotTreeEdit.vue node by node. This is additive on top of v1/v2 — nothing in the data
model (§2) or SupportBotAdminService validation (§4.3) changes; v3 exposes new transports
in front of the same logic already designed for v1.
15.1 REST web services (granular CRUD)
Why a new surface is needed: v1's admin mutation endpoint (§4.1) is deliberately
whole-tree-at-once — SupportBotAdminController::saveTree takes the entire node/option graph
in a single payload, which fits a human editing in the tabular UI but is a poor shape for a
conversational agent. An agent naturally works one step at a time — "create a tree," "add a
node," "add another option to it," "point that option at the earlier node" — so v3 adds a
parallel, granular REST surface: same tables, same validation service, a different transport
aimed at programmatic/agent-driven authoring rather than the human UI.
Promote SupportBotTree, SupportBotNode, SupportBotOption to first-class
#[ApiResource] entities, in addition to (not instead of) the existing DTO-based
SupportBotTreeList/SupportBotTreeDetail read views and the whole-tree-save controller:
| Resource |
Operations |
Notes |
SupportBotTree |
GetCollection, Get, Post, Patch, Delete |
Post creates an empty draft tree; nodes/options are added afterward via their own resources |
SupportBotNode |
GetCollection (filterable by tree), Get, Post, Patch, Delete |
|
SupportBotOption |
GetCollection (filterable by node), Get, Post, Patch, Delete |
|
Patch, not Put, for updates — verified against API Platform's own upgrade guide/changelog:
API Platform 4 removed Put from the default generated operations entirely (fix: remove PUT from default operations, api-platform/core#6570); Patch (JSON Merge Patch, RFC 7396) is now
the only update operation registered by default. Put still exists but is opt-in, and once
explicitly declared it performs full standards-compliant resource replacement
(standard_put: true is itself now the default for any Put operation that is declared,
reversing API Platform 3's partial-update-via-PUT behavior). This plan uses Patch for all
three resources, both because it matches the framework's new default and because it's the
better fit for how an MCP-driven agent actually edits: adding one option, renaming one node's
content, or repointing one targetNode are all naturally partial updates — sending a full
Put replacement for a one-field change would require the agent to first fetch and resend
every unrelated field, which is unnecessary round-tripping and additional risk of the agent
accidentally clobbering fields it didn't mean to touch. Put is deliberately not added to
any of these three resources unless a concrete need for full-replacement semantics appears.
- Security:
security: "is_granted('ROLE_ADMIN')" on every operation of all three resources
— no new authorization model, the same gate as the rest of this feature (§9).
- Every mutating operation routes through
SupportBotAdminService via a Processor (the
same "Processor calls the existing service, doesn't duplicate its rules" pattern already
used for TicketAdminService), so §4.3's validation stays the single source of truth
regardless of which transport triggered it.
- Only the structural, per-object rules from §4.3 are enforced on each individual
create/update (e.g. "a targetNode must belong to the same tree," "referenced ids must
exist"). The graph-wide rules — every node reachable from the entry node, every leaf
terminates — are deliberately not re-checked on every single node/option write, because
a tree built up one resource at a time legitimately passes through incomplete states (a
freshly created node has zero options for a moment, by construction). Those rules are
enforced only when the tree is explicitly published, exposed here as its own operation: a
publish custom operation on SupportBotTree that returns either "published" or the list
of validation errors with the specific node/option refs to fix.
- Bulk JSON/CSV import (§5) is unchanged and remains the fastest way to load a fully-formed
tree in one call. The granular resources here are for incremental, conversational editing —
exactly the shape an agent-driven session takes.
15.2 API Platform 4 MCP declaration
Prerequisite, called out explicitly: this requires upgrading api-platform/core from the
project's current 3.0 to the 4.x line, where API Platform's native MCP (Model Context
Protocol) server support lives. That is a project-wide, major-version dependency bump, not
something scoped to the Support Bot feature — every other #[ApiResource] in the codebase
needs its own compatibility pass, so this belongs on its own, separately-reviewed upgrade
ticket. §15.1 (REST) does not depend on that upgrade and is useful standalone; §15.2 (MCP)
only becomes buildable once the project (or at least this feature) is actually on 4.x.
What MCP exposure is expected to look like. The description below is the current
understanding of API Platform 4's newly-introduced MCP support, not a verified API surface —
it must be checked against API Platform 4's actual documentation once the team is on that
version, and treated as provisional until then:
- API Platform 4 exposes API Resources as MCP tools through a project-level MCP server
endpoint, opt-in via configuration (e.g. api_platform.yaml: mcp: enabled: true). One MCP
tool is generated per exposed operation, named from the resource/operation (e.g.
list_support_bot_trees, get_support_bot_tree, create_support_bot_node,
publish_support_bot_tree), with the tool's description populated from that operation's
OpenAPI summary/description. Only the three §15.1 resources should opt in — this is
not a blanket "expose the whole platform to MCP" change; every other #[ApiResource] in
the codebase keeps MCP disabled unless a separate, deliberate decision is made for it.
- Because an MCP tool's description is what an agent uses to decide when to call it, those
summaries need to be written for that audience specifically: action-oriented, unambiguous
against any other "create"/"list" tool the platform might expose, and explicit about side
effects (publish_support_bot_tree's description should say it makes the tree live for
real users, not just "update tree status").
- Security carries over unchanged: MCP calls are ordinary authenticated requests to the
same Symfony security layer, so ROLE_ADMIN and every §9 mitigation apply exactly as they
do to the REST calls in §15.1 — there is no separate MCP authorization model to design. The
calling agent authenticates as a real Chamilo admin user via the JWT/API-token mechanism
this codebase already uses elsewhere for headless, non-browser API access.
- Recommend a dedicated automation admin account for any agent granted MCP access, rather
than a shared human admin's personal token, so every tree/node/option created this way stays
attributable via the existing createdBy/updatedAt fields (§2.2) — a tree an agent built
should be visibly distinguishable from one a human built in the same list view (§4.4), not
merely inferable from timing.
- A validate-without-publishing tool is needed alongside the CRUD tools: expose §4.3's
graph checks as their own validate_support_bot_tree tool, separate from publish, that
returns the same errors without flipping status. This is what makes MCP authoring
actually usable — an agent's natural workflow is create → validate → fix → validate again →
publish, and without a non-destructive validate step it would only discover a broken tree
by actually trying to publish it.
- The bulk import/export endpoints (§5) and the whole-tree-save endpoint (§4.1) should not
be exposed as MCP tools. A single tool call asking an LLM to produce an entire tree as one
large structured JSON blob is a worse fit than the granular per-node/per-option tools above
— long structured output in one call is exactly where LLMs are more error-prone. Granular
tools plus the validate/publish loop give the agent a much shorter, more correctable unit of
work per call.
15.3 Illustration — agent-driven authoring session
Admin (via an MCP client, e.g. Claude) MCP server → REST → SupportBotAdminService
│ "Create a support tree for password │
│ reset issues" │
│ ──────────────────────────────────────────▶│ create_support_bot_tree()
│ │ create_support_bot_node() ×N
│ │ create_support_bot_option() ×N
│ "Validate it" │
│ ──────────────────────────────────────────▶│ validate_support_bot_tree()
│ ◀──────────── "node n4 is unreachable" ─────│
│ "Fix that, then publish" │
│ ──────────────────────────────────────────▶│ update_support_bot_option()
│ │ publish_support_bot_tree()
│ ◀───────────────── "Published, v2" ─────────│
Support Bot — Rigid Decision-Tree Chatbot
Implementation plan for an optional Chamilo core feature: a support chatbot driven by a
fixed (non-AI) decision tree, reachable from the existing chat dock, with an authoring UI,
a conversation review UI, escalation to the existing Ticket system (v1), and a roadmap for
branching/canvas/live-agent hand-off (v2) and REST + MCP-based authoring for AI agents (v3).
This document describes what to build, not how to write the code. No code in this
repository is changed by this document.
0. Naming and scope conventions used throughout this plan
SupportBot*(SupportBotTree,SupportBotNode,SupportBotOption,SupportBotConversation,SupportBotStep).course-scoped — same placement as the
Ticket*entities, not likeAiTutorConversationwhich is tied to a
Course.ticket(per requirement — this feature is a sibling of theexisting ticket settings, since its escalation path is the ticket system).
a visual canvas editor, and live-agent handoff are explicitly deferred to v2 (§14).
Exposing tree authoring as REST + MCP tools for AI agents is deferred to v3 (§15).
1. Feature flag (setting) — must land first
Everything else is built behind this flag. New settings in this codebase are added by
extending
SettingsCurrentFixtures.php(§1.2) — that's the authoritative, single place asetting's name/category/title/comment is defined, and it's what the requirement's "fixture"
reference means.
TicketSettingsSchema.php(§1.1) additionally defines the default value andthe form field type for the admin Settings UI. A migration (§1.3) is still needed so existing
installations pick up the new row, since fixtures only run on fresh installs/tests — but that
migration reruns the existing generic fixtures-upsert logic rather than hardcoding the new
setting's name in custom SQL.
1.1
src/CoreBundle/Settings/TicketSettingsSchema.phpAdd one boolean setting, following the exact pattern of the existing
ticket_*booleans inthis file:
buildSettings(): add'ticket_support_bot_enabled' => 'false'to thesetDefaults()array.buildForm(): add->add('ticket_support_bot_enabled', YesNoType::class).This is the only setting needed for v1. No JSON/textarea setting is required (unlike
ai_providers) because v1 has nothing to configure beyond on/off — which tree is "active"is a property of the tree itself (
status = published), not a global setting (see §3).1.2
src/CoreBundle/DataFixtures/SettingsCurrentFixtures.phpIn
getExistingSettings(), add one entry to the existing'ticket' => [...]array (the onestarting at line ~1531):
[ 'name' => 'ticket_support_bot_enabled', 'title' => 'Enable support chatbot', 'comment' => 'Show a decision-tree chatbot in the chat panel that can guide users to ' .'an answer or open a support ticket on their behalf.', ],This entry in
SettingsCurrentFixtures.phpis the single source of truth for thesetting's name/category/title/comment — this is how every setting in this project is added,
and it's sufficient on its own for fresh installs and PHPUnit/Behat test databases (which are
seeded from fixtures directly).
1.3 Existing installations: settings-upsert migration
Fixtures only run on fresh installs and test databases — an upgrade of an existing site only
runs migrations, it never re-runs
DataFixtures. This codebase already has a generic,reusable mechanism for exactly this gap:
Version20260721125301.php(its own descriptioncalls it "a fresh re-run of
Version20250926174000's fixtures-upsert logic") readsSettingsCurrentFixtures::getExistingSettings()andgetNewConfigurationSettings()directlyand, for every entry not yet present in the
settingstable, inserts it (or updates itstitle/category/comment if the row already exists but drifted).
Because that logic reads the fixtures file itself rather than hardcoding variable names, no
bespoke
INSERT INTO settings ...needs to be written forticket_support_bot_enabled.What's needed is: add the entry to
SettingsCurrentFixtures.php(§1.2), then add one moremigration that reruns this same upsert logic — copy
Version20260721125301.php's body intoa new class with a fresh timestamp and a description naming this feature (the same way that
file itself was a rerun of the one before it). It will pick up
ticket_support_bot_enabledautomatically. This migration is separate from the one thatcreates the new tables (§2.3) — schema changes and settings upserts are kept as separate,
single-purpose migrations throughout this codebase's history, and there's no reason to break
that convention here.
1.4 Runtime check
Nowhere in this feature should a new "feature access helper" class be introduced —
AiFeatureAccessHelperexists because AI features have a three-state mode(
true/false/plugin_defined) and per-course configurability. This feature is a plainon/off platform toggle, so every consumer reads it the same one-line way the existing
ticket_allow_student_addsetting is read inTicketListProvider:A second, independent condition always applies alongside the setting: a published tree
must exist for the current
AccessUrl(or a portal-agnostic one,accessUrl IS NULL—same fallback convention as
TicketProject/TicketStatus/TicketPriority). If the settingis on but no tree is published, the feature stays invisible. This avoids a half-configured
feature appearing with nothing behind it.
2. Data model (v1 — rigid tree)
2.1 Entity-relationship sketch
2.2 Entities
SupportBotTree(tablesupport_bot_tree)AccessUrl, nullableTicketProjectdraft|published|archivedpublishedtree peraccessUrl(enforced in the admin service, not a DB constraint — same style as other Chamilo enum-like string columns)SupportBotNode, nullable (FK)TicketProject, nullable (FK)User(FK)SupportBotNode(tablesupport_bot_node)SupportBotTree(FK, cascade delete)A node with zero
SupportBotOptionrows is implicitly a dead end and must be flaggedinvalid by the admin save validation (§4.3) — every leaf must end via an option whose
actionTypeisend_conversationorescalate_ticket, never by simply having no options.This keeps traversal logic in one place (options), not split between "nodes with no options"
and "options with terminal actions".
SupportBotOption(tablesupport_bot_option)SupportBotNode(FK, cascade delete)goto_node|open_link|escalate_ticket|end_conversationSupportBotNode, nullable (FK)actionType = goto_nodeactionType = open_link; validated, see §9 open-redirect noteTicketCategory, nullable (FK)actionType = escalate_ticketactionType = end_conversationSupportBotConversation(tablesupport_bot_conversation)SupportBotTree(FK)tree.versionat start — so a tree edit mid-conversation doesn't retroactively change what's shown to an in-progress user, and so review (§7) always reflects what the user actually sawUser(FK)AccessUrl, nullableSupportBotNode, nullable (FK)in_progress|resolved|escalated|abandonedTicket, nullable (FK)outcome = escalatedSupportBotStep(tablesupport_bot_step)SupportBotConversation(FK, cascade delete)SupportBotNode(FK)SupportBotOption, nullable (FK)SupportBotStepis the append-only transcript log — structurally the same role asAiTutorMessage, but storing "which node was shown / which option was picked" instead offree-text role/content pairs, since the tree is rigid and there's nothing else to log.
2.3 Migration
One new file in
src/CoreBundle/Migrations/Schema/V200/, in the same style as the existingmigrations there (
Schema+$this->addSql(...),up()/down()):(tree_id)on node;(node_id)on option;(tree_id, user_id)and(access_url_id)on conversation (mirrorsidx_ai_tutor_conv_user_course/idx_ai_tutor_conv_course);(conversation_id, created_at)on step (mirrorsidx_ai_tutor_msg_conv_created).This migration only touches the 5 new tables — it does not insert the
ticket_support_bot_enabledsettings row; that's a separate settings-upsert migration(§1.3).
3. Runtime engine (backend)
New service:
src/CoreBundle/Service/SupportBot/SupportBotEngine.php(mirrorssrc/CoreBundle/Service/Ticket/TicketWorkflowService.php's role: it is the single place thatenforces conversation rules, called by the runtime controller).
Responsibilities:
AccessUrl's published tree (fallback tothe portal-agnostic one, same lookup pattern as
TicketListProvider::getProjects()); ifnone, throw/return "unavailable". Create a
SupportBotConversationrow pointing attree.entryNode,outcome = in_progress, and a firstSupportBotStepwithnode = entryNode,selectedOption = null.conversationIdand aoptionId:conversation.currentNode(defends against a clientsending an option id that isn't actually offered at the current step — never trust the
client's idea of "what node it's on").
SupportBotStepwithselectedOptionset.option.actionType:goto_node: setconversation.currentNode = option.targetNode, append a new stepfor that node with
selectedOption = null, return the new node + its options.open_link: return the URL to the caller (client opens it in a new tab); theconversation does not advance or end — the same node/options are returned again
so the user can still pick a different option afterwards.
end_conversation: setconversation.outcome = resolved,endedAt = now,currentNode = null; returnoption.closingMessage(or a generic default).escalate_ticket: create aTicket(see §6), setconversation.outcome = escalated,resultingTicket,endedAt = now,currentNode = null; return a confirmation payload containing the new ticket's id/url.explicit "abandon" call; a scheduled cleanup (existing Chamilo cron mechanism, e.g. a new
consolecommand run daily) marks anyin_progressconversation withstartedAt < now - 24hasoutcome = abandoned. This keeps the review screen (§7) fromshowing stale "in progress" rows forever.
4. Admin authoring
4.1 API surface
Mirrors the Ticket module's split:
#[ApiResource]+ProviderDTOs for read-heavy list/detail screens (like
TicketList/TicketListProvider,TicketDetail), a plain#[AsController]for mutations (likeTicketAdminController).ApiResource/SupportBot/SupportBotTreeList.php,State/SupportBot/SupportBotTreeListProvider.phpApiResource/SupportBot/SupportBotTreeDetail.php,State/SupportBot/SupportBotTreeDetailProvider.phpApiResource/SupportBot/SupportBotConversationList.php,State/SupportBot/SupportBotConversationListProvider.phpApiResource/SupportBot/SupportBotConversationDetail.php,State/SupportBot/SupportBotConversationDetailProvider.php#[AsController],ROLE_ADMINController/Api/SupportBotAdminController.php,Service/SupportBot/SupportBotAdminService.phpAll admin endpoints:
#[IsGranted('ROLE_ADMIN')]at the controller level. This feature hasno "session admin" or delegated-editor concept in v1 — not because it's hard, but because
nothing today asks for partial delegation of tree editing; if that need shows up later it can
be added the same way
TicketCategoryRelUserdelegates ticket-category responsibility,without changing anything else in this plan.
4.2 CSRF
Per project convention:
SupportBotAdminService::CSRF_TOKEN_ID = 'support_bot_admin'.SupportBotTreeListProviderandSupportBotTreeDetailProvidereach return acsrfTokenfield in their JSON payload (exactly like
TicketList::$csrfToken); every mutating call inSupportBotAdminControllervalidates it with$this->isCsrfTokenValid('support_bot_admin', $token)before touching anything.4.3 Save/validation rules (enforced server-side in
SupportBotAdminService, not just client-side)actionTypeisend_conversationorescalate_ticket(a leaf silently having zero options is rejected — see §2.2).entryNodemust belong to the tree being saved.targetNodereferenced by agoto_nodeoption must belong to the same tree(prevents an admin payload from wiring a node into a foreign tree — see §9 mass-assignment
note).
entryNodemust be reachable by following at least oneoption chain from
entryNode(simple graph traversal in PHP; reject with the list ofunreachable node refs so the admin can fix them). This is cheap to check because v1 has no
cycles by construction (every edge points from a node to one created after/alongside it in
the editor — see §14.1 for what changes once branching allows loops).
status = published, incrementsversion, and demotes any otherpublishedtree on the sameaccessUrltoarchived(only one active tree per portal).4.4 Vue views
New directory
assets/vue/views/supportbot/:SupportBotTreeList.vue—BaseTable: title, status badge, version, portal scope, updateddate. Row actions: edit (
secondary-text/pencil), duplicate (secondary-text), publish/unpublish toggle, delete (
danger-text/delete, behinduseConfirmation), "Viewconversations" link. Header actions: "+ Create" (
success), "Import" (success, opens aBaseDialogfile-upload), "Export" per row (primary).SupportBotTreeEdit.vue— the tabular node/option editor (see mockup below). Left: areorderable list of nodes (add/remove/reorder + "set as entry node"). Right: a form for the
selected node —
content(BaseTextArea), and a repeatable list of options, each withlabel(BaseInputText),actionType(BaseSelect), and the matching conditional field(
targetNodeaBaseSelectof the tree's own nodes /targetUrlaBaseInputText/ticketCategoryaBaseSelect/closingMessageaBaseTextArea). One "Save tree" buttonsubmits the whole node+option graph in a single request (matches §4.3 — validation needs
the whole graph at once anyway).
SupportBotConversationList.vue/SupportBotConversationDetail.vue— see §7.Tabular editor mockup (v1 — no canvas, see §13.2 for the v2 alternative):
5. Import / export format
Primary format: JSON. A CSV alternative is offered because non-technical support staff
often draft trees in a spreadsheet first — both import to the exact same validated model
(§4.3 rules apply identically regardless of source format).
5.1 JSON schema
{ "tree": { "title": "Video playback & login help", "description": "First-line support tree for common LMS issues", "defaultTicketProject": "Support" }, "nodes": [ { "ref": "n1", "content": "Hi! What do you need help with?", "options": [ { "label": "A video won't play", "action": "goto_node", "target": "n2" }, { "label": "I can't log in", "action": "goto_node", "target": "n5" } ] }, { "ref": "n2", "content": "Does the video fail on all devices or just one?", "options": [ { "label": "All devices", "action": "goto_node", "target": "n3" }, { "label": "Just one device/browser", "action": "goto_node", "target": "n4" } ] }, { "ref": "n3", "content": "Please check your internet connection and try our compatibility guide.", "options": [ { "label": "That fixed it, thanks!", "action": "end_conversation", "closingMessage": "Glad we could help!" }, { "label": "Still not working", "action": "escalate_ticket", "ticketCategory": "Technical" } ] } ], "entryNode": "n1" }refis an import-time-only string alias (so an option can reference a node defined furtherdown in the file, before it has a real database id). The importer resolves every
target/entryNoderef to a node id and discards the refs — they are never persisted.5.2 CSV alternative
One row per option (a node with 2 options produces 2 rows sharing the same
node_ref/node_content):5.3 Export
"Export" on
SupportBotTreeList.vuedownloads the tree in the JSON format above — thisdoubles as a backup/versioning mechanism (an admin can keep exported trees in their own git
repo) and as the round-trip format for duplicating a tree across portals.
6. Escalation to a human agent (Ticket integration)
When an option's
actionType = escalate_ticketfires (§3, step 2):TicketProject:option.ticketCategory?.project ?? tree.defaultTicketProject;if neither resolves, fail loud (admin misconfiguration — this must be caught at
publish-time by §4.3, not silently swallowed at runtime).
Ticket.messagebody from the conversation transcript: render everySupportBotStepas"{node.content}\n→ {selectedOption.label}", joined in order. Thisgives the human agent the full path the user took without them re-asking the same
qualifying questions.
Ticketvia the existingTicketWorkflowService(do not duplicate ticketcreation logic) with
category = option.ticketCategory,projectas resolved above,insertUserId = conversation.user,subject= a generated string such as"Support Bot: {tree.title}".conversation.resultingTicket,outcome = escalated.the new ticket (
{ name: 'TicketDetail', params: { id } }), rendered as aBaseButtoninside the chat bubble.
No new ticket-side code is needed — this is pure composition on top of the existing Ticket
entities/service listed in §0.
7. Conversation logging & review
SupportBotConversationList.vue(admin, per tree) —BaseTablefilterable by outcome,date range, and free-text keyword (matched against step content via a
LIKE, same styleas
TicketListProvider::applyFilters()'s keyword search). Columns: user, started/endedat, outcome badge (
Activeblue /Resolvedgreen /Escalatedgray with a ticket link /Abandonedred — reusing the badge convention already in use elsewhere), tree version.SupportBotConversationDetail.vue— read-only chat-transcript rendering of the orderedSupportBotSteprows (bot bubble =node.content, "user" bubble =selectedOption.label),plus a link to
resultingTicketif escalated. No edit capability — this is an audit view,not a place to alter history.
8. Vue: chat dock integration (
assets/vue/components/chat/DockedChat.vue)This file already has the exact shape needed, built for the AI Tutor peer
(
const AI_PEER_ID = -1). Add a second synthetic peer:tutorCtx.enabled && !contactsHasAiTutor), gated by a newsupportBotCtx.enabledflagfetched the same way
tutorCtxis — except it is not gated byinCourse: the bot isplatform-wide, so it must be visible from any page, not only inside a course.
text. Add a third bubble kind, "options", rendered as a stack of
BaseButtonelements (oneper current
SupportBotOption), used only whenactivePeer.id === SUPPORT_BOT_PEER_ID.When this peer is active, the free-text input box is hidden entirely — the tree is rigid,
there is nothing for the user to type (except in the escalate-confirmation state, which is
just a button, not text either).
(§3) with
{ conversationId, optionId, _token }; the response (next node + its options, ora closing/escalation payload) is appended to the transcript exactly like a new incoming
message today.
entry node.
9. Security checklist (per CLAUDE.md Rule 13 — OWASP)
SupportBotAdminController: save tree, delete, publish, import) and every runtime mutation (SupportBotRuntimeController: start conversation, advance conversation)CsrfTokenManagerInterface::getToken('support_bot_admin' / 'support_bot_chat'), validated viaisCsrfTokenValid(); token round-tripped in the JSON payload (there is no HTML<form>here, so the token travels as a JSON field returned by the GET/start call and echoed back on the next POST, same mechanismTicketList::$csrfTokenalready uses)SupportBotAdminControllerroutes,SupportBotTreeList/SupportBotConversationListproviders#[IsGranted('ROLE_ADMIN')]at controller level — no session-admin exception needed since v1 has no delegated editors (§13 candidate)SupportBotRuntimeController::advanceConversationconversation.user === currentUserbefore any read/write; return 404 (not 403) on mismatch to avoid confirming the conversation id exists, same reasoning as the "per-user owned ApiResource" pattern in CLAUDE.mdSupportBotTreeListProvider/SupportBotConversationListProviderfilters and sortingsetParameter); sort field allowlist map, never a raw client string intoorderBy()— exact copy ofTicketListProvider::applySorting()'s$sortMappatternSupportBotNode.contentrendering inDockedChat.vueand in the admin transcript view{{ }}auto-escapes; nov-htmlfor node content anywhere, unlike the legacy-contacts-HTML branch already inDockedChat.vuewhich is a separate, pre-existing code path. If rich text is ever wanted, it must be sanitized server-side before storage, not just escaped at render timeSupportBotOption.targetUrl(open_linkaction)http(s)://absolute URL against an admin-configured allowlist of domains (reuse whatever mechanism, if any, already validates external links elsewhere — otherwise restrict to relative paths only for v1 and revisit); render as<a target="_blank" rel="noopener">or open viawindow.open(), never assign towindow.location.hreffrom unsanitized dataSupportBotAdminController::saveTree(whole-graph payload)node.tree_id === tree.id) before any write — an admin editing tree A must not be able to silently attach/detach nodes that belong to tree B; array of node ids cast witharray_map('intval', ...)10. Vue routing & breadcrumbs
No new top-level Symfony route is needed. The only new pages are admin-only (the tree editor
and the conversation review — end users never leave the chat dock to use the bot itself), so
nesting everything under the existing
/admin/{vueRouting}catch-all(
IndexController::index(), already registered) avoids adding a new entrypoint route and anew
Breadcrumb.vuewhitelist entry — the/admin/*breadcrumb case documented inBreadcrumb.vuealready applies automatically.Add to
assets/vue/router/admin.js'schildrenarray:The generic Settings admin screen already exposes
ticket_support_bot_enabledonce §1 isdone — no bespoke settings page is needed.
11. Admin menu entry
Add one item to
getItemsTracking()(or a more fitting section, e.g. next to the existingitem-ticket-systementry) insrc/CoreBundle/Controller/Admin/IndexBlocksController.php:Gate its visibility with the same
ticket_support_bot_enabledsetting check (follow thiscontroller's existing pattern for conditionally-shown items) so the menu entry disappears
when the feature is off.
12. Testing plan
12.1 PHPUnit
SupportBotEngine: start conversation resolves the right tree (portal-specific overportal-agnostic fallback); advancing with a foreign option id is rejected; each
actionTypebranch produces the expected conversation/step state; escalation creates areal
Ticketwith the expected category/project and transcript text.SupportBotAdminService: every §4.3 validation rule has a dedicated failing case (missingentry node, cross-tree target, unreachable node, leaf without a terminal option); publish
demotes the previously published tree; import (JSON and CSV) produces an identical model
to hand-building the same tree via the admin service.
conversation id gets 404; a non-admin calling any
SupportBotAdminControllerroute gets403; a request missing/with a wrong CSRF token is rejected on every mutating endpoint.
12.2 Behat (
tests/behat/features/supportbot/, mirroring the view directory)Per the mandatory rule in
CLAUDE.md, every interaction needs coverage, and — since thispage is accessible only to admins for authoring/review but to every logged-in role for
using the bot — scenarios must run once per relevant role:
manage-trees.feature(admin only): create a tree with a couple of nodes/options,edit it, publish it, delete it, import a JSON file, export a tree. Include a scenario that
a non-admin (student, teacher) cannot reach
/admin/support-bot(redirected/denied).use-chatbot.feature, run once as a student and once as a teacher: open the chatdock, start the bot, walk a full path to a
resolvedend, and separately walk a path toescalate_ticketand confirm a ticket appears in/ticketsfor that user.review-conversations.feature(admin only): after a scenario like the above runs, theadmin can see the conversation in the list, filter it by outcome, and open its transcript.
Every form control in the new Vue views must carry a
nameattribute per the project rule,so these steps can use
I fill in "name" with "value"/I select "option" from "name".Each feature file creates and tears down its own tree/settings state, leaving the database as
found (per the project's Behat self-containment rule) — in particular, restoring
ticket_support_bot_enabledto its prior value at the end.13. Rollout notes
setting at its default (
false) and no published tree, the feature is completely inert —zero visible surface, negligible runtime cost (one settings lookup + one indexed query per
page that renders
DockedChat.vue, cached the same waytutorCtxalready is).Ticket,TicketCategory,TicketProjectare only ever read by this feature, never modified in shape.
first so PHPUnit/Behat DBs have the schema and the setting early.
SupportBotEngine+ runtime controller, tested via PHPUnit only (no chat UI yet).SupportBotAdminService/SupportBotAdminController) +SupportBotTreeList/SupportBotTreeEditVue views — an admin can now author and publish a tree end-to-end.DockedChat.vueintegration — a user can now actually talk to the bot.rather than only at the end).
14. V2 roadmap (explicitly out of scope for v1)
These three items were identified while scoping v1 and are real, separately-shippable
features. Listed here so v1's data model choices (§2) don't have to be redesigned later —
each subsection notes what in v1 already accommodates it and what doesn't.
14.1 Branching (conditional tree traversal)
Problem v1 doesn't solve: a fixed button click is the only way to move through the
tree. There's no way to ask the user to type something (an order number, a course name)
and branch on it, and no way to branch on facts Chamilo already knows about the user
(role, enrollment, session membership) without making them click through a redundant
question.
Data model additions:
inputnode — asks the user to type/pick a value (text, number, or asmall enum), stored as a named variable scoped to the conversation.
SupportBotVariable(conversation-scoped key/value store): one row per(conversation_id, name).SupportBotOptiongains a nullableconditionExpressioncolumn. Evaluated server-sidewith Symfony's
ExpressionLanguagecomponent (sandboxed expression evaluation — nevereval()), against the conversation's variables plus a small set of built-in facts(
user.role,user.hasActiveSession, etc., resolved by the engine, not user-supplied).Options are evaluated in
sortOrder; the first whose condition is true (or that has nocondition at all, i.e. a "fallback/else" option) wins.
be reachable through more than one path, and — if ever desired — loops (e.g. "ask again if
the input didn't validate"). The reachability/leaf-validation rules in §4.3 need
generalizing to a proper graph-reachability check (still cheap, just no longer "every edge
points forward").
Illustration — v1's fixed edges vs. v2's conditional edges on the same node:
14.2 Visual flowchart canvas editor
Problem v1 doesn't solve: the tabular editor (§4.4) is fast to use but doesn't show the
overall shape of a large tree — an admin authoring 40+ nodes may struggle to see at a
glance which branches are getting long or where a loop (once v2.1 allows them) closes.
Approach: add a second, optional front-end over the same data model — no new
entities beyond two presentational columns,
positionX/positionY(float), added toSupportBotNode. A drag-and-drop canvas library (e.g. Vue Flow) renders nodes as boxes andoptions as directional connectors; dragging a connector from one node's option handle onto
another node updates that option's
targetNode/conditionExpressionexactly as the tabulareditor's dropdowns would. Both views stay available — some admins will prefer the tabular
list for fast linear edits, others the canvas for seeing structure; this is purely additive,
not a replacement.
Needed canvas behaviors: pan/zoom, add-node context menu, click-node opens the same
content/options mini-form used in the tabular editor (as a side panel or modal, not
reinvented), and live validation highlighting (e.g. a node with no incoming edge glows red as
"unreachable", a non-leaf with zero outgoing edges glows red as "dead end") reusing the exact
rules from §4.3/§14.1.
Illustration — same tree as the tabular mockup in §4.4, as a canvas would render it:
14.3 Live agent hand-off (real-time human takeover)
Problem v1 doesn't solve: escalation in v1 is always asynchronous — it opens a
Ticketand the conversation ends there. There is no way for the user to be connected, in the same
chat session, to a human agent typing back in real time.
Approach: add a fifth
actionTypeonSupportBotOption:live_handoff. Firing it doesnot end the conversation the way
escalate_ticketdoes — instead:conversation.outcomegains a new value,awaiting_agent.DockedChat.vuealready shows in its header (userStatus/"Online"/"Offline") — any userwith
ROLE_ADMIN(or a new, narrowerROLE_SUPPORT_AGENTif platforms want to delegatethis without full admin rights) who is marked online is "available".
SupportBotLiveQueue.vue— a list of conversations currentlyawaiting_agent, each with a "Claim" button. Claiming is a single conditional UPDATE(
SET claimedByUserId = :agent WHERE id = :id AND claimedByUserId IS NULL) so two agentsracing to claim the same request can't both succeed — first write wins, matching
optimistic-concurrency conventions already used elsewhere in the codebase.
DockedChat.vueissimply reassigned from
SUPPORT_BOT_PEER_IDto the claiming agent's real user id, and theexisting human-to-human messenger flow (the same one
ChatController/ChatRepositoryalready serve today) takes over untouched — the bot's job was only to route the
conversation to a person, not to relay messages between them.
prompts the user to fall back to
escalate_ticketinstead (v1's async path), so thefeature degrades gracefully when no one is online rather than leaving the user stuck.
Illustration — hand-off sequence:
15. V3 roadmap — REST web services + MCP-based tree authoring for AI agents
Goal: let an administrator build/edit a Support Bot tree by directing an AI agent (e.g.
an MCP-compatible client such as Claude) instead of clicking through
SupportBotTreeEdit.vuenode by node. This is additive on top of v1/v2 — nothing in the datamodel (§2) or
SupportBotAdminServicevalidation (§4.3) changes; v3 exposes new transportsin front of the same logic already designed for v1.
15.1 REST web services (granular CRUD)
Why a new surface is needed: v1's admin mutation endpoint (§4.1) is deliberately
whole-tree-at-once —
SupportBotAdminController::saveTreetakes the entire node/option graphin a single payload, which fits a human editing in the tabular UI but is a poor shape for a
conversational agent. An agent naturally works one step at a time — "create a tree," "add a
node," "add another option to it," "point that option at the earlier node" — so v3 adds a
parallel, granular REST surface: same tables, same validation service, a different transport
aimed at programmatic/agent-driven authoring rather than the human UI.
Promote
SupportBotTree,SupportBotNode,SupportBotOptionto first-class#[ApiResource]entities, in addition to (not instead of) the existing DTO-basedSupportBotTreeList/SupportBotTreeDetailread views and the whole-tree-save controller:SupportBotTreeGetCollection,Get,Post,Patch,DeletePostcreates an emptydrafttree; nodes/options are added afterward via their own resourcesSupportBotNodeGetCollection(filterable bytree),Get,Post,Patch,DeleteSupportBotOptionGetCollection(filterable bynode),Get,Post,Patch,DeletePatch, not Put, for updates — verified against API Platform's own upgrade guide/changelog:
API Platform 4 removed
Putfrom the default generated operations entirely (fix: remove PUT from default operations, api-platform/core#6570);Patch(JSON Merge Patch, RFC 7396) is nowthe only update operation registered by default.
Putstill exists but is opt-in, and onceexplicitly declared it performs full standards-compliant resource replacement
(
standard_put: trueis itself now the default for anyPutoperation that is declared,reversing API Platform 3's partial-update-via-PUT behavior). This plan uses
Patchfor allthree resources, both because it matches the framework's new default and because it's the
better fit for how an MCP-driven agent actually edits: adding one option, renaming one node's
content, or repointing one
targetNodeare all naturally partial updates — sending a fullPutreplacement for a one-field change would require the agent to first fetch and resendevery unrelated field, which is unnecessary round-tripping and additional risk of the agent
accidentally clobbering fields it didn't mean to touch.
Putis deliberately not added toany of these three resources unless a concrete need for full-replacement semantics appears.
security: "is_granted('ROLE_ADMIN')"on every operation of all three resources— no new authorization model, the same gate as the rest of this feature (§9).
SupportBotAdminServicevia aProcessor(thesame "Processor calls the existing service, doesn't duplicate its rules" pattern already
used for
TicketAdminService), so §4.3's validation stays the single source of truthregardless of which transport triggered it.
create/update (e.g. "a
targetNodemust belong to the same tree," "referenced ids mustexist"). The graph-wide rules — every node reachable from the entry node, every leaf
terminates — are deliberately not re-checked on every single node/option write, because
a tree built up one resource at a time legitimately passes through incomplete states (a
freshly created node has zero options for a moment, by construction). Those rules are
enforced only when the tree is explicitly published, exposed here as its own operation: a
publishcustom operation onSupportBotTreethat returns either "published" or the listof validation errors with the specific node/option refs to fix.
tree in one call. The granular resources here are for incremental, conversational editing —
exactly the shape an agent-driven session takes.
15.2 API Platform 4 MCP declaration
Prerequisite, called out explicitly: this requires upgrading
api-platform/corefrom theproject's current 3.0 to the 4.x line, where API Platform's native MCP (Model Context
Protocol) server support lives. That is a project-wide, major-version dependency bump, not
something scoped to the Support Bot feature — every other
#[ApiResource]in the codebaseneeds its own compatibility pass, so this belongs on its own, separately-reviewed upgrade
ticket. §15.1 (REST) does not depend on that upgrade and is useful standalone; §15.2 (MCP)
only becomes buildable once the project (or at least this feature) is actually on 4.x.
What MCP exposure is expected to look like. The description below is the current
understanding of API Platform 4's newly-introduced MCP support, not a verified API surface —
it must be checked against API Platform 4's actual documentation once the team is on that
version, and treated as provisional until then:
endpoint, opt-in via configuration (e.g.
api_platform.yaml: mcp: enabled: true). One MCPtool is generated per exposed operation, named from the resource/operation (e.g.
list_support_bot_trees,get_support_bot_tree,create_support_bot_node,publish_support_bot_tree), with the tool's description populated from that operation'sOpenAPI
summary/description. Only the three §15.1 resources should opt in — this isnot a blanket "expose the whole platform to MCP" change; every other
#[ApiResource]inthe codebase keeps MCP disabled unless a separate, deliberate decision is made for it.
summaries need to be written for that audience specifically: action-oriented, unambiguous
against any other "create"/"list" tool the platform might expose, and explicit about side
effects (
publish_support_bot_tree's description should say it makes the tree live forreal users, not just "update tree status").
same Symfony security layer, so
ROLE_ADMINand every §9 mitigation apply exactly as theydo to the REST calls in §15.1 — there is no separate MCP authorization model to design. The
calling agent authenticates as a real Chamilo admin user via the JWT/API-token mechanism
this codebase already uses elsewhere for headless, non-browser API access.
than a shared human admin's personal token, so every tree/node/option created this way stays
attributable via the existing
createdBy/updatedAtfields (§2.2) — a tree an agent builtshould be visibly distinguishable from one a human built in the same list view (§4.4), not
merely inferable from timing.
graph checks as their own
validate_support_bot_treetool, separate frompublish, thatreturns the same errors without flipping
status. This is what makes MCP authoringactually usable — an agent's natural workflow is create → validate → fix → validate again →
publish, and without a non-destructive validate step it would only discover a broken tree
by actually trying to publish it.
be exposed as MCP tools. A single tool call asking an LLM to produce an entire tree as one
large structured JSON blob is a worse fit than the granular per-node/per-option tools above
— long structured output in one call is exactly where LLMs are more error-prone. Granular
tools plus the validate/publish loop give the agent a much shorter, more correctable unit of
work per call.
15.3 Illustration — agent-driven authoring session