Files
mcpctl/docs/personalities.md
Michal 4cbf58d212
Some checks failed
CI/CD / lint (pull_request) Successful in 54s
CI/CD / test (pull_request) Failing after 1m8s
CI/CD / typecheck (pull_request) Successful in 2m35s
CI/CD / smoke (pull_request) Has been skipped
CI/CD / build (pull_request) Has been skipped
CI/CD / publish (pull_request) Has been skipped
feat(mcpd+deploy): serve web UI at /ui + smoke tests + docs (Stage 6)
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>
2026-04-26 19:48:43 +01:00

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 (or personality: "<name>" in the chat request body), or
  • set agent.defaultPersonalityId on the agent — used when no --personality flag 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.

See also

  • agents.md — the parent resource.
  • chat.mdmcpctl chat flow + LiteLLM-style flags.