Admin UI
The Agent Admin UI (mc-agent-admin-ui-app) is the visual console for the
runtime. It runs on http://localhost:9090.
Running it
By default the Admin UI runs without authentication — no Keycloak, no
Docker, no database. It starts immediately and logs you in as a fixed dev user
(mc_user). The only required setting is the encryption key for stored LLM
credentials:
export MINDCONNECT_ENCRYPTION_SECRET_KEY="change-me-to-a-32-char-secret!!!"
mvn -f agents/server/mc-agent-admin-ui-app/pom.xml spring-boot:run
The key must be 16, 24 or 32 characters long (it is used directly as an AES key) — any other length fails at the first encrypt/decrypt.
Open http://localhost:9090 — you're in.
Under the hood this is the mindconnect.auth.enabled=false mode (the default):
all routes are permitted, CSRF is off, and no OIDC is wired. You can change the
auto-login username with MC_DEV_USER.
Optional: Keycloak login (for deployments)
For a deployed instance you'll want real authentication. Activate the
keycloak Spring profile and the app logs users in through Keycloak —
the profile provides the OAuth2 client registration and flips auth.enabled on
together. (Setting MC_AUTH_ENABLED=true alone does not work: without the
profile there is no client registration and the app fails to start.) Keycloak
must be running before you start the app, or login will fail.
1. Start Keycloak first
cd agents/server/mc-agent-admin-ui-app
cp .env.docker.example .env.docker # first time: fill in the passwords
./start-keycloak.sh
This brings up Keycloak (+ its Postgres) from docker-compose.yml via
podman compose (install podman first, or run the compose file with docker
yourself) and imports the mindconnect realm. Keycloak is then at
http://localhost:8180 (realm: mindconnect, client: mc-admin-ui).
2. Start the Admin UI with the keycloak profile
MINDCONNECT_ENCRYPTION_SECRET_KEY="change-me-to-a-32-char-secret!!!" \
mvn -f agents/server/mc-agent-admin-ui-app/pom.xml spring-boot:run \
-Dspring-boot.run.arguments=--spring.profiles.active=keycloak
Open http://localhost:9090 and log in via Keycloak.
Keycloak users
The imported mindconnect realm contains these seed users (passwords are set
from the KC_PASSWORD_MC_* values in .env.docker):
| Username | Realm role |
|---|---|
mc_user | user |
mc_admin | admin |
mc_hr | hr |
mc_dev | dev |
Fixing podman clock drift (fix-podman-clock.sh)
If you run Keycloak in a podman machine on macOS, the VM clock falls behind
whenever the Mac sleeps. Keycloak then signs JWTs with an expiry in the past and
OIDC login fails with Jwt expired at ....
The cause: podman's default chrony config (makestep 1.0 3) only steps the
clock during the first few NTP updates after boot, then refuses large
corrections — so a long suspend leaves the VM permanently behind.
The fix is a one-time script:
cd agents/server/mc-agent-admin-ui-app/keycloak
./fix-podman-clock.sh
It patches the VM's chrony to makestep 1.0 -1 (step at any time), restarts
chronyd, and forces an immediate resync. The change persists until
podman machine reset, after which clock drift self-heals within ~64s of each
future suspend.
Run it once per podman machine — or any time login starts failing with a "Jwt expired" error after your Mac has been asleep.
Branding the app
The Admin UI can carry your own name and look instead of Mindconnect's — set
mindconnect.branding.* and nothing has to be rebuilt:
mindconnect:
branding:
title: ACME Assistants # heading in the header, and the login page
document-title: ACME Admin # the browser tab; unset it follows title
logo: /branding/acme.svg # the mark beside the heading; "" for none
logo-href: /admin/agents # where a click on the brand leads
favicon: /branding/favicon.png # the tab icon; unset it follows the logo
theme: default # the look it opens in (see below)
stylesheets: # linked last, so their rules win
- /branding/acme.css
assets-dir: /etc/acme/branding # served at /branding/**
Every setting is optional; unset, the app looks exactly as it ships.
Where the assets come from. An asset URL is used verbatim, so all three
forms work: a path into the app's own resources (/img/logo.svg), a path into
assets-dir (/branding/acme.svg), and an absolute URL on a CDN. assets-dir
is the one that keeps branding a deployment concern: point it at a directory
next to the running app, drop a logo and a stylesheet in, and the files are
served at /branding/** — no rebuild, no custom image. They are reachable
without a login, because the login page wears them.
The stylesheet is linked after every shipped one, so it only has to set the
framework's tokens — --sui-color-primary, --sui-header-bg, … — and every
control follows, the same way the bundled themes work. The login page takes the
same tokens, so it is branded along with the rest.
:root {
--sui-color-primary: #0a192c;
--sui-color-action: #0a192c;
--sui-header-bg: #0a192c;
}
The theme picker in the header offers every shipped theme unless
style-picker says otherwise — disabled: true takes it away entirely (and
then the look is genuinely fixed: neither the menu, nor ?theme=, nor what
the browser remembers can change it), themes: narrows it to a list:
mindconnect:
branding:
style-picker:
themes: amethyst, default
theme picks which shipped look the shell opens in (amethyst — the
default —, clody, gipiti, sorbet, vscode-dark, vscode-light,
vscode-hc, compact, dark, or default for the framework's bare one). A theme is a class on <html> and therefore wins on
specificity over a :root rule, so a branding stylesheet written as above
wants theme: default underneath it. The theme picker in the header still
overrides the setting per browser.
A palette that several installations share is better off as a theme than
as a branding stylesheet: a theme is a class on <html>, so it can restyle
components (the nav rail's active row, the tabs, the toasts) and not just set
tokens, and it shows up in the header's theme picker. A theme is one file in
static/css/ in mc-agent-admin-ui-rest, linked in index.html and listed
in js/theme-switch.js — the shipped ones are the worked examples. A branding
stylesheet is then left with what a theme cannot reach: the login page, which
has no app shell and therefore no theme class.
A whole branded instance is usually a Spring profile: put the settings above
into application-<name>.yaml, point assets-dir at a directory beside the
app, and start it with --spring.profiles.active=<name>.
One process, several brands
A profile decides the brand when the process starts, which means one process
per brand. switch decides it per request instead, from the host in the
address bar — the same deployment behind two names wears two brands:
mindconnect:
branding:
# what every other host gets
title: Mindconnect Agent Runtime
assets-dir: /etc/mindconnect/branding
switch:
acme:
url-pattern: "*.acme.*"
title: ACME AI
logo: acme.svg # a bare name: a file in assets-dir
stylesheet: acme-sui.css # "stylesheets:" for more than one
style-picker:
disabled: true # this brand's look is not up for discussion
mindconnect:
url-pattern: app.mindconnect.ai
logo: mindconnect-logo.svg
style-picker:
themes: amethyst, default
Each entry takes the same keys as the block above it, plus the url-pattern
that claims a host. What to know:
- The pattern is a glob over the host name — no port, no path, case
ignored.
*stands for any run of characters, dots included, so*.acme.*coversagents.acme.comandai.acme.co.ukalike. - The first matching entry wins, in the order they are declared. Write the specific pattern above the wildcard.
- What an entry leaves unset comes from the top level, and what the top level leaves unset is what the app ships. An entry says only what makes it different.
- No match is not an error. A host nobody claims —
localhost, a health check, an internal name — gets the top-level branding. - A bare file name is a file in
assets-dir, which is what keeps aswitchblock from repeating/branding/on every line. A path (/img/logo.svg) or an absolute URL is used as written.
Since the assets of every brand are served from the same /branding/**, give
them distinct names (acme.svg, mindconnect-logo.svg) rather than a
directory each.
Who may work where
A namespace holds everything — agents, LLM configs, workflows, skills, sessions, files, vector stores — and it says who may do what in it, in two lists of e-mail addresses. A new namespace is not empty: the first time it is used it gets the shipped agents, LLM configs, skills and workflows, and the content of every extension that is on there (see Initial data).
| Admin | User | |
|---|---|---|
| Chat | yes | yes |
| Agents, LLM configs, workflows, skills, MCP servers, vector stores | yes | no |
| The namespace's variables | yes | no |
| Invite, promote, demote, remove | yes | no |
| Rename | yes | no |
| Delete the namespace | its creator only | no |
| Leave | yes | yes |
The creator is one of the admins and the one entry that cannot be taken out of that list — a namespace without an admin would be nobody's to clean up. They are also the only one who may delete it: an admin they promoted shapes the namespace, but does not throw away everyone's work.
People are listed by address, not by account. An address exists before an
account does, so somebody can be invited before they have ever signed in — the
membership is waiting at their first sign-in, with nothing to send and nothing
pending. A name without an @ gets mindconnect.email-domain appended, so an
installation whose accounts are all <name>@company.example invites by name; a
guest is invited with their own address.
Being signed in is not being let in
mindconnect.namespace-admins names who shapes the default namespace
(mindconnect.namespace, local unless configured), and naming anybody
closes it:
mindconnect:
namespace: local
namespace-admins: chief@company.example, david@company.example
email-domain: company.example
From then on an account this installation lists nowhere is shown a page saying
so, with the way to sign out — not an empty app — and the API answers 403
rather than falling back into the default namespace. Nobody is put into a
namespace by arriving under a host: an admin has to invite them.
Left unset, the default namespace stays open to every signed-in user, who is an admin there. That is what a single-user installation and the dev mode run on, and it is why nothing changes for them.
A namespace per brand
A switch entry may name the namespace its hosts work in:
switch:
acme:
url-pattern: "*.acme.*"
title: ACME AI
namespace:
admins: [chief@acme.example, david@acme.example]
The namespace's id is the entry's own name (acme) unless id: says
otherwise, its display name is the brand's title, and the first admin is its
creator. creator: <address> is the short form of a single admin.
It is created the first time somebody arrives under one of that brand's hosts, and never changed from configuration afterwards — who is in a namespace is its admins' business, not that of a file edited later. Somebody listed in it lands there on their first sign-in; somebody listed anywhere also gets a namespace of their own, with them as its admin.
The main sections
The navigation has eight top-level entries:
| Section | What you do there |
|---|---|
| Agents | Create, edit, delete and copy agents. In the detail view: configure tools and start or continue sessions. |
| Tools | Browse the available tools, inspect their schemas, and test a tool. |
| Skills | Create, edit and delete the instruction packs agents load on demand. |
| LLM Configs | Create, edit, delete and test LLM configs. API keys come from environment variables. |
| Workflows | The embedded workflow admin UI (mc-workflow-admin-rest): edit, save and run workflows. |
| Vector Stores | Manage vector-store templates and stores, upload files, run semantic searches. |
| Migrations | Review and apply changes to the bundled seed data (agents, LLM configs, workflows). |
| Extensions | What the jars on the classpath declare they bring, and the switch per namespace. |
| API | Embedded Swagger UI for the REST API under /api/**. |
The task manager
The header carries a small badge that says what the task queue is doing right
now — 3 running · 2 waiting, or idle. It is live: the page attaches to the
user's own server-sent event stream (/admin/api/stream) once, and the stream
stays attached while you navigate, so the count is current on every page
without polling.
The same stream carries what happens in your sessions while you are not
looking at them: a turn started or finished, a tool waiting for your answer, a
chat that got its title. The chat's history reflects it — a conversation with
a turn in flight says running where its age would be, one with an approval
card open says needs input — and it does so in every tab, whichever one
sent the message.
One thing to know about tabs: over HTTP/1.1 a browser keeps at most six
connections open per site, and every stream the app holds is one of them —
the user stream on every page, plus the session stream on a chat. A few chats
in a few tabs reach that limit, and from then on every request queues behind
the streams and pages stop loading. The tabs count their streams together and
warn you once the sum comes within one of the limit; close the tabs you no
longer need and the notice goes away. Served over HTTP/2 there is no such
limit and no notice. To see what one tab holds, type console.table(mc.streams())
into the browser console: a chat page lists user-stream and one
msg-list-…, any other page only user-stream.
A click on the badge opens the task queue, the admin UI's Task Manager:
- Running now — the live tasks as a tree, the way the queue links them:
a chat turn, under it the tool calls it dispatched, and under a
run_agentcall the sub-agent's turn. Every row shows the agent or tool, what it is doing (session title, round), the status (running,queued,suspendedwhile a turn waits on its sub-tasks,cancelling), the user it belongs to and how long it has been at it. - Finished recently — the last tasks that completed, failed or were cancelled; a failed one opens to show the reason.
- Cancel — the ban icon on a row. Cancel is cooperative (a running task stops at its next checkpoint) and cascades: cancelling a turn takes its tool calls and sub-agents with it. It is offered for your own tasks only — the tasks whose session belongs to the signed-in user; other users' tasks are visible but not cancellable.
The dialog updates itself over the same stream while it is open.
My tools
An agent brings the tools whoever built it chose. The My tools tab on your profile is where you add your own — they are offered in every chat of yours, on top of what the agent brings, and only in your chats.
Adding one is two steps: which tool, then how. The second step is where the interesting part is:
- Name in your chats. Leave it empty and you are changing the agent's own
entry for that tool. Give it a name —
email_arbeit— and you get a second entry instead. Add the same tool twice with two names and you have one per account. - Account. For a tool that runs on something you connected, pick which one. Whichever is my default follows the mark on the Connections tab, so moving the default moves this tool with it; picking one fixes it, and the model cannot change it.
- What to tell the model. "Reads my work mailbox" is what lets it pick the right one of the two without being told every time.
- Ask me before every call. Only ever adds a question. It cannot take away one the agent already asks for — an approval is tightened by any layer and relaxed by none.
Switching one off takes it out of your chats without forgetting how it was set up. Removing it forgets.
Sub-agents do not get them. When an agent hands work to a sub-agent, that sub-agent keeps the tools its own definition gives it — somebody chose that list for a narrow job, and a research helper that silently gained the ability to send mail would be a surprise nobody asked for. Your connections are a different matter: a sub-agent whose own definition has a mail tool runs it on your mailbox, because the call still runs as you. It uses your default account, so if you keep two and mean the other one, do that part in the chat itself.
One thing you cannot do: switch on a tool the installation switched off. The registry refuses it whoever asks.
Connections
Some tools run on an account that is yours and nobody else's — a mailbox, a calendar. The Connections tab on your profile is where you attach them.
One card per thing the installed tools ask for, with one or two ways to attach an account.
Connect sends you to the provider's own sign-in page — Google, Microsoft —
and brings you back with the account attached. Nothing is typed here and no
password is stored: what comes back is a token, kept encrypted, renewed by
itself while you use it. If the provider ever refuses to renew it, the
connection says expired with the reason and Connect again is all it takes.
Add manually opens a form — host, account, password, whatever that provider needs — and what you save is yours alone: a password is stored encrypted and never shown to anyone, including you. Everything else stays readable, so a typo in a hostname can be corrected without typing the password again.
You may attach several. Two mailboxes, "Privat" and "Arbeit", are a normal thing to have. One of them is the default: it is what a tool uses when a chat does not say otherwise, and you can move that mark at any time. As soon as you have two, the model is offered the choice — it sees exactly your names and cannot invent a third — so "look in my work mail" does what it says.
Renaming is safe. Tools refer to a connection by a short, fixed form of the name it had when you attached it; the label above it is yours to change.
Removing one takes the tools that used it out of service until you attach another; the next connection of that provider takes over as default.
A Connect button only appears where this installation has the provider's app
registration. The redirect it comes back to is
https://<your host>/admin/oauth/callback, and that exact address has to be
registered with the app — providers do not accept wildcards, so one entry per
installation.
An installed tool that asks for an account you have not attached raises a notification on your next sign-in — that is the only nudge you get, and it clears itself the moment you attach one.
Notifications
Beside the task badge — which says what the server is doing — the header carries a bell, which says what is waiting for you. It shows the number of notices you have not read; a click opens the panel, and opening it is what marks them read.
A notice is one line, sometimes a paragraph under it, and often a link to the place that settles it: "Still to set up: Mail server → Set it up". Dismiss takes one off the list, Dismiss all clears it.
Where they come from: an installation is rarely finished the moment your
account exists. A tool may want a mailbox configured, a provider your own API
key — and you cannot be expected to guess that MC_EMAIL_HOST is the name it
looks up. On the first request after you sign in, the installation looks at
what the installed tools declare they need,
fills in what has a sensible default, and raises one notice per value that is
still missing.
Two things follow from how they are stored, and both are deliberate:
- A notice does not pile up. The check runs on every sign-in and says the same thing every time; each notice carries the condition it stands for, so the second sign-in finds the entry that is already there — read or unread — and leaves it alone. Dismiss it and it is not raised at you again.
- A notice clears itself. Set the value and the next sign-in stops reporting it; the entry disappears without your having to tidy up. Should the condition come back — a variable removed, a key revoked — you get a fresh notice rather than silence.
For hosts: anything can contribute a notice by implementing SetupCheck as a
Spring bean and returning Notification.Drafts for whatever is missing right
now. NotificationService handles the collapsing, and UserSetup clears what
a check has stopped reporting. Where no NotificationRepository is assembled
there is no bell and nothing is raised.
Time zone
Your agents read a time without an offset — "tomorrow at 16:16", a calendar
entry at 2026-09-25T16:16 — in your time zone, and show times in it; the
system prompt tells them which one it is. Account → Time zone on your
profile shows it and lets you change it; the next tool call uses the new one.
You rarely have to: the first time your browser is seen, the zone it reports
becomes yours. It happens once — travelling does not move your calendar
around, and a zone you chose is never overwritten. Until then (and for work on
nobody's behalf) the installation's zone applies: MC_TIME_ZONE
(mindconnect.time-zone), else the server's.
Preferences
A screen may remember where you left it — the folder that was open, the view you chose, the column you sorted by — and open there the next time, on any device you sign in from.
For hosts: inject PreferenceService and keep what a screen remembers under a
scope of its own (office.email, admin.agents), as string values:
preferences.merge(user, "office.email", Map.of("mailbox", mailboxId, "folder", folderId));
String folder = preferences.get(user, "office.email", "folder").orElse("INBOX");
Changing one value keeps the others of the scope, and a write that changes
nothing writes nothing, so storing the state on every navigation is fine.
Names are at most 64 characters, a scope holds at most 100 keys and a value at
most 4,096 characters. Preferences live beside the users — a file per user and
scope under system/preferences/, or the table mc_user_preference — and they
are not the user's variables: not encrypted, not passed to tools, not shown
under Variables. A value a tool should read belongs there instead. Where no
PreferenceRepository is assembled there is no PreferenceService; a screen
should treat that as "remembers nothing".
Related
- Environment variables — every variable you can set (API keys, models, Keycloak, …).
- Creating a tool — how a tool declares the variables it needs from each user.
- LLM configuration reference — the bundled configs and their fields.
- Bundled agents — the agents that ship out of the box.