The closing stage. mcpd now hosts the Stage 5 SPA, the Docker image bundles the build artifact, a smoke test exercises the personality HTTP surface end-to-end, and the user-facing docs spell out the mental model. mcpd: - Add @fastify/static dep. - New routes/web-ui.ts: registers /ui/* against a static bundle. Looks for the bundle at $MCPD_WEB_ROOT, then /usr/share/mcpd/web (the Docker image path), then a dev-tree fallback. Logs and skips cleanly if missing — API-only deploys keep working. - SPA fallback: any /ui/<path> that doesn't match a file falls through to index.html so direct hits to react-router URLs work. - /ui/* falls through to `kind: skip` in mapUrlToPermission, so the static assets are served unauthenticated. Each API call from the SPA still carries the bearer token. Deploy: - Dockerfile.mcpd builds the @mcpctl/web bundle in the same builder stage and copies dist/ to /usr/share/mcpd/web in the runtime image. Smoke (personality.smoke.test.ts): - Live mcpd flow: create secret/llm/agent/personality, attach an agent-direct prompt, verify the binding listing, reject double- attach (409) + foreign-agent prompt (400), set defaultPersonality by name, detach + delete cleanup. Docs: - New docs/personalities.md: VLAN-on-ethernet model, system-block ordering table, three prompt scopes, CLI walkthrough, web UI walkthrough, full API surface, RBAC notes. - agents.md and chat.md cross-link. - README's Agents section gains a Personalities subsection. Test count after Stage 6: mcpd: 801/801 cli: 430/430 web: 7/7 db: 58/62 (4 pre-existing) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5.7 KiB
Personalities & agent-direct prompts
A personality is a named overlay of prompts on top of an existing
agent. Same agent, same LLM, same systemPrompt — but a different bundle
of additional context injected at chat time.
The mental model is a VLAN on top of ethernet: ethernet works on its own, and a VLAN tag adds segmentation without replacing the underlying link. Without a personality, an agent runs exactly as before. With one selected, its bound prompts get appended to the system block.
What goes into the system block
When you call an agent's chat endpoint, mcpd assembles the system block in this order (top wins by appearing first in the prompt):
agent.systemPrompt
+ agent-direct prompts (Prompt.agentId == agent.id, priority desc)
+ project prompts (Prompt.projectId == agent.projectId, priority desc)
+ personality-bound prompts (PersonalityPrompt[chosen], priority desc)
+ systemAppend (per-call override, --system-append)
Picking a personality is per-turn. Either:
- pass
--personality <name>on the CLI (orpersonality: "<name>"in the chat request body), or - set
agent.defaultPersonalityIdon the agent — used when no--personalityflag is given.
Without either, today's behavior holds: agent + project prompts only.
Three prompt scopes
A Prompt row attaches to at most one of projectId or agentId.
Every prompt fits exactly one of these slots:
| Scope | projectId |
agentId |
Where it shows up |
|---|---|---|---|
| Global | null |
null |
Any chat (passes the personality's "in scope" check) |
| Project | set | null |
All agents whose projectId matches |
| Agent-direct | null |
set | Only this agent. Always-on overlay, no toggle |
Personality bindings (PersonalityPrompt) further select which of those
prompts get injected when that personality is active. The service layer
enforces a scope rule: a prompt can only be bound to a personality if
it's already in-scope for that agent (agent-direct, agent's project, or
global). Foreign-project prompts are rejected with HTTP 400.
CLI
# Make a personality on an existing agent
mcpctl create personality grumpy --agent reviewer --description "Be terse and slightly grumpy"
# Add an agent-direct prompt (always-on for this agent)
mcpctl create prompt always-terse --agent reviewer --content "Always be terse." --priority 8
# Bind the prompt to the personality (HTTP for now — CLI subcommand to come)
PERSONALITY_ID=$(mcpctl get personalities -o json | jq -r '.[] | select(.name=="grumpy") | .id')
PROMPT_ID=$(mcpctl get prompts always-terse -o json | jq -r '.[0].id')
curl -sf -H "Authorization: Bearer $(jq -r .token ~/.mcpctl/credentials)" \
-H "Content-Type: application/json" \
-X POST "https://mcpctl.ad.itaz.eu/api/v1/personalities/${PERSONALITY_ID}/prompts" \
-d "{\"promptId\": \"${PROMPT_ID}\", \"priority\": 9}"
# Use it
mcpctl chat reviewer --personality grumpy
> what's wrong with this code?
# Make it the default for this agent
mcpctl edit agent reviewer
# … in the YAML editor, set:
# defaultPersonality:
# name: grumpy
The chat banner shows which personality (if any) is active before the first prompt:
────────────────────────────────────────────────────────────
Agent: reviewer — code review agent
LLM: qwen3-thinking Project: code-quality
Personality: grumpy (--personality)
System prompt:
You are a senior code reviewer. Be terse...
────────────────────────────────────────────────────────────
Web UI
The browser editor at https://mcpctl.ad.itaz.eu/ui/ covers the same
operations with Monaco for prompt editing:
- Projects → :name → prompts: project-scoped prompt CRUD.
- Agents → :name → Direct prompts: agent-direct prompt CRUD.
- Agents → :name → Personalities: list, create, drill into a personality to bind/unbind prompts. The "attach prompt" picker only shows in-scope candidates (agent-direct, same-project, or global).
The web UI uses the same bearer token as the CLI — paste a session
token (mcpctl auth login writes one to ~/.mcpctl/credentials) or
mint a long-lived PAT (mcpctl create mcptoken …). The token is kept
in localStorage; logout clears it.
API surface
GET /api/v1/personalities
GET /api/v1/personalities?agent=<name>
GET /api/v1/agents/:agentName/personalities
POST /api/v1/agents/:agentName/personalities
GET /api/v1/personalities/:id
PUT /api/v1/personalities/:id
DELETE /api/v1/personalities/:id
GET /api/v1/personalities/:id/prompts
POST /api/v1/personalities/:id/prompts body: { promptId, priority? }
DELETE /api/v1/personalities/:id/prompts/:promptId
GET /api/v1/agents/:agentName/prompts # agent-direct prompts only
POST /api/v1/prompts # body: { name, content, agent: <name>, priority? }
# XOR with project: <name>
Chat request body now accepts an optional personality: "<name>". RBAC:
all personality endpoints inherit agents:view/edit/create/delete. There
is no separate personalities resource in RBAC bindings — managing a
personality is part of managing the parent agent.