Two lines in src/appExtensions/activeExtensions.ts — an import, and an entry in
the array:
import { exampleAppExtension } from "./example/exampleExtension"; // 1. import it
export const activeAppExtensions: readonly AppExtensionConfig[] = [
exampleAppExtension, // 2. add it
];That is the whole integration surface. Installing a second extension is one more entry; nothing else in the application changes.
Nothing is installed by default, including the bundled example. To see the example
running without editing the file, set KAGENT_UI_VITE_EXAMPLE_EXTENSION=true — in ui/.env,
or on the command line as KAGENT_UI_VITE_EXAMPLE_EXTENSION=true yarn dev.
This UI is built to be extended without editing the application itself. A distribution contributes navigation entries, whole pages, components at named points inside existing pages, extra form fields, API operation overrides and payload transforms, and app-level React providers — all declared in one configuration object per extension.
The model is deliberately close to Backstage plugins: a plugin is a self-contained module, and installing it costs a small, explicit edit in the host application rather than magic discovery.
activeAppExtensions is an ordered array, and the order is the precedence. An
extension's config is the same shape whether it is installed alone or alongside
others — what the array decides is only how two of them are reconciled. Two rules
cover everything:
| Rule | |
|---|---|
navItems, routes, formFields, tableColumns, providers, slots |
Additive. Every extension's contributions take effect, in array order. |
theme, shell, branding, navOverrides, agentLinks, providerIcons, chatPartRenderers, routeHandles, api |
Merged, later wins, field by field. An extension replacing the header does not blank a sidebar an earlier one replaced. |
So list the extension whose opinion should prevail last.
| Capability | With two installed |
|---|---|
navItems |
Concatenated, then re-sorted by order — so a contribution lands at its position regardless of which extension supplied it, and two extensions' entries interleave with each other as well as with the application's. |
routes |
Concatenated. Two extensions claiming one path is a startup error. replaces is a union: a core route named by any extension is dropped. |
slots |
Every contribution renders, in array order, inside the one wrapper the point declares. |
formFields |
Concatenated, then sorted by order within the target form. |
tableColumns |
Concatenated, each placed after the core column its after names. |
providers |
Concatenated and nested outermost-first, so the earlier extension's providers wrap the later one's. |
theme.tokens / modeTokens |
Folded one level deep in order; the later extension wins a token both name, and keeps one only the earlier names. |
theme.antd.components |
Merged per component, then per token — so naming one property of Input does not discard the others. |
theme.globalStyles |
All emitted, in order, after the application's own — later wins a tie. |
theme.stylesheets |
All loaded; an href already present is not fetched twice. |
theme.supportedModes |
Intersected, not last-wins. It is a statement that an extension's own components cannot be read in the other palette, and a second install does not make the first one's components legible. A mode is offered only while every extension that has an opinion can honour it. |
agentRailItems |
Concatenated, then re-sorted by order, exactly like navItems but for the agent rail. |
agentRailOverrides |
Merged two levels, exactly like navOverrides but for the agent rail's own entries. |
navOverrides |
Merged two levels — per entry, then per field. One extension hiding an entry and another renaming it gives a hidden, renamed entry rather than whichever spoke last. |
api.transforms / api.request |
Compose. They are a list applied in registration order, so both extensions' transforms run and the later one sees the earlier one's work. |
api.operations / endpoints / baseUrl |
Single-valued, so the later extension wins. |
An absent field never overwrites: omitting something means "no opinion", which is what lets a second extension change the header without blanking the sidebar the first one replaced.
Collisions that cannot be reconciled — two extensions claiming the same route path or the same nav key, or the same extension installed twice — are rejected at startup rather than resolved arbitrarily. See Validation.
A complete worked example — the Example App Extension — lives in
src/appExtensions/example/. The application ships with no extension
installed, so a default build renders only what this project itself provides.
Switch it on with:
KAGENT_UI_VITE_EXAMPLE_EXTENSION=true yarn devor by putting KAGENT_UI_VITE_EXAMPLE_EXTENSION=true in ui/.env, which .env.example
documents. It is read by the bundler rather than at runtime, so changing it needs
the dev server restarted.
Read that directory alongside this document — it exercises every extension point described here.
interface AppExtensionConfig {
id: string; // stable machine id, e.g. "example"
name: string; // human-readable name
navItems?: readonly ExtensionNavItemContribution[]; // sidebar entries
routes?: readonly ExtensionRouteContribution[]; // whole pages
slots?: ExtensionSlotComponents; // components at named points
formFields?: readonly ExtensionFormFieldContribution[]; // extra fields in core forms
api?: ExtensionApi; // operation overrides + transforms
providers?: readonly ExtensionProviderComponent[]; // app-level context providers
}Only id and name are required. A config that contributes a single nav item is
a complete, valid config. This is one extension's whole declaration; installing it
alongside others changes nothing about the shape.
The extension always supplies the whole renderer. There is no "just give me a label and a link" shorthand anywhere, and none will be added. Partial configuration surfaces multiply forever — every consumer eventually needs one more property, an icon, a badge, a tooltip, a variant — and each one becomes a compatibility obligation for this project. Requiring a component costs an extension a few lines once and costs the host nothing thereafter.
If you want something that looks exactly like a core element, import the core component and render it yourself.
order is the only positioning input. Core items sit at multiples of 100, so
250 lands between Agents (200) and Models (300) — contributions interleave
with the application's own navigation rather than being appended after it.
const exampleNavItem: ExtensionNavItemContribution = {
key: "example", // unique across core and contributed items
order: 250,
path: "/example", // used for active-state matching only
Component: ExampleNavItem, // receives { isActive, collapsed }
};Your component renders its own link. path is optional and exists only so the
framework can tell you whether you are the active item; collapsed says whether
the rail is in its narrow form, which the renderer cannot work out for itself.
To sit in the list rather than beside it, render the same antd Menu the rail
uses — that is what the bundled example does. A hand-rolled link is free until
it has to line up, and then it has to reproduce the row height, the inset pill,
the icon column and the collapsed layout, and drift from all four the next time
the library changes any of them. useThemeMode() is re-exported from the barrel
for the light/dark choice the Menu takes, since no design token stands in for
it.
The rail beside a conversation has the same pair of points the sidebar has, for the
same reasons. Its own entries are agentDetails at order 100 and newChat at 200.
agentRailItems: [
{
key: "runs", // unique across core and contributed entries
order: 150, // between Agent Details (100) and New chat (200)
path: "/runs", // active-state matching only
Component: RunsRailItem, // receives { isActive, agent }
},
],
agentRailOverrides: {
newChat: { label: "Start a run" },
agentDetails: { path: "/my/agent", icon: Sliders },
}instance is the conversation the rail is drawn beside, when there is one. It is absent
on the agent's own page and on a new conversation, so an entry that needs an address
should derive it and render nothing when it cannot — which is what the application's
own entries do when their destination cannot be derived.
agent carries the runnable Agent's namespace and optional name, including on
Agent and new-chat pages with no conversation open. An entry that links to the
Agent definition should check agent?.name; template and Harness choices do not
change that identity.
An override's path lands on the entry's to; the rail renders its links itself, so
that is the same idea under the name the rail uses for it. hidden also suppresses
the button "New chat" falls back to where an agent has no address, so hiding it is
not half done.
To make a contributed entry indistinguishable from the application's, style it with
agentRailEntryStyles(theme, isActive), which is what the rail uses for its own. An
entry that wants to look nothing like them is free to; it should not have to.
A contributed route renders in the application layout, not inside the agent page, so a page reached from a rail entry does not get the rail for free. Mount it:
import { AgentRail, useAgentConversations, useAgentInstance } from "@/appExtensions";
const instance = useAgentInstance(id);
const conversations = useAgentConversations(namespace, agentName);
<AgentRail
instanceRef={{ id }}
instance={instance.data}
instances={{ ...conversations, data: conversations.data?.all ?? [] }}
/>The rail takes the open conversation and its siblings rather than reading them, because
every surface mounting it already lists them. Pass agentTitle and agentRef too when
no conversation is open, or the identity card has nothing to name. The contributed entry
stays lit while its own page is showing, as any core entry would.
Contributed routes are merged into the router ahead of the catch-all, so *
keeps meaning "not found". A contributed page renders inside the app shell by
default, because an extension page is a page of this application rather than a
separate site. standalone: true opts out for full-screen flows such as your own
login.
routes: [
{ path: "/example", element: <ExamplePage /> },
{ path: "/example/onboarding", element: <ExampleOnboarding />, standalone: true },
]A path that collides with a core route is rejected at startup — see Validation.
Every point the application offers is listed in EXTENSION_POINT_IDS. IDs are
shaped app_<area>_<page>_<component>_<slot>, so the name alone says where the
point lives.
| Extension point ID | Context passed to your component |
|---|---|
app_shell_appHeader_actions_leading |
none |
app_shell_appLayout_contentArea_leadingBanner |
none |
app_shell_appLayout_contentArea_globalOverlay |
none |
app_shell_appLayout_appSidebar_footer |
none |
app_agents_agentsList_pageHeader_actions |
none |
app_agents_agentsList_agentListItem_badge |
{ agentName: string; namespace: string } |
app_agents_agentChat_agentChatMessage_additionalActionsButton |
{ messageId: string; role: "user" | "agent"; text: string } |
app_agents_agentChat_snapshotDetails_footer |
{ snapshotId: string; instanceId: string; label: string } |
app_agents_agentChat_snapshotDivider_actions |
{ snapshotId: string; instanceId: string; label: string } |
app_dashboard_dashboardOverview_summaryGrid_leadingCard |
none |
Mount a component by naming the point:
slots: {
app_shell_appLayout_contentArea_leadingBanner: ExamplePolicyBanner,
app_agents_agentsList_agentListItem_badge: ExampleAgentBadge, // gets agentName + namespace
}The ID union is derived from the runtime list, so a point cannot exist in the
type system without also existing at runtime. A typo is a compile error, and
tsc will suggest the correct ID.
Each point declares how it reaches the DOM, in EXTENSION_POINT_RENDER_MODE:
inline— rendered where the slot sits. Correct whenever the contribution belongs in the surrounding layout flow. This is nearly everything.portal— rendered intodocument.body. Used only where a contribution must escape its parent's DOM position. Today that isapp_shell_appLayout_contentArea_globalOverlay: the content area is anoverflow: autoscroll container with its own stacking context, so a floating overlay declared inside it would be clipped by the scroll box and trapped beneath sibling chrome.
Prefer inline. Reach for portal only when clipping or stacking genuinely
requires it.
A contributed field declares its own component and how its value maps into and out of the request payload — necessary because a downstream API rarely uses the same shape as the reference one.
Target forms are listed in EXTENSION_FORM_IDS:
app_agents_agentNew_agentForm, app_models_modelNew_modelForm,
app_mcpServers_mcpServerNew_mcpServerForm.
export const exampleTeamField = defineExtensionFormField({
id: "exampleTeam",
formId: "app_agents_agentNew_agentForm",
Component: ExampleTeamField,
fromPayload: (payload) => payload.metadata?.labels?.["example.com/team"] ?? "",
toPayload: (payload, value) => ({
...payload,
metadata: {
...payload.metadata,
labels: { ...payload.metadata?.labels, "example.com/team": value },
},
}),
validate: (value) => (value ? undefined : "Pick a team"),
});fromPayload seeds the field when editing; toPayload writes it wherever your
API expects it; validate returns a message or undefined.
A slot cannot add a column. A slot occupies a position in the DOM, whereas a column is a heading, a per-row renderer and a place in an ordering — three things that must be declared together for a table to lay out at all.
This is how a product whose domain is wider than this application's shows that extra dimension on a page the application still owns. Nothing replaces the page.
export const creatorColumn = defineExtensionTableColumn<AgentInstance>({
id: "creator",
tableId: "app_agents_agentsList_table",
title: "Creator",
after: "namespace", // positioned after that core column's key
render: (row) => row.creator ?? "—",
});Target tables are listed in EXTENSION_TABLE_IDS. after naming a column the table
does not have puts the contribution at the end rather than dropping it, so a core
table can lose a column without an extension's disappearing with it.
Pages fold contributions in with withExtensionColumns, so adding one needs no
change to the page.
Keyed by the data layer's own operation IDs, so naming a call that does not exist fails to compile.
api: {
baseUrl: "https://api.example.com", // optional: replace the API root
transforms: {
"models.list": {
request: (context) => ({
...context,
headers: { ...context.headers, "x-example-tenant": currentTenant() },
}),
response: (body) => unwrapExampleEnvelope(body),
},
},
}request runs after the URL resolves and before the call is sent; response
runs on the parsed body before it reaches the caller. Both may be async.
installExtensionApi folds this declarative shape into the data layer's
runtime registry — resolution itself belongs to src/api, so there is exactly
one description of a request in the codebase.
A backend usually demands something of all its traffic rather than of one endpoint — an authorization header, a tenant, a correlation id. The per-endpoint table is the wrong shape for that: it means an entry per endpoint, and the endpoint somebody adds next week goes out without it.
api: {
baseUrl: "https://api.example.com",
request: (context) => ({
...context,
headers: { ...context.headers, authorization: `Bearer ${token()}` },
}),
}This hook runs last — after baseUrl and after any per-endpoint transform —
so context.url is the URL the request will actually be sent to. That ordering is
the point rather than an accident: a hook attaching a credential needs to be able
to tell a call bound for the extension's own backend from one going anywhere
else, and it can only do that if it sees the final destination.
It cannot call hooks — it runs per request, outside React. Anything it needs from application state has to be reachable without one: a module-level value, storage, or something a provider published on its way past.
A slot changes what is inside it. A product with its own design language needs the application's components to look different too — its buttons, tables, inputs and headings, none of which the extension owns.
Overriding design tokens is what achieves that. Every component in this project reads its colours, radii and fonts from the tokens, so replacing values restyles components the extension never touches.
theme: {
tokens: {
color: { primary: "#0084c0", primaryHover: "#006ba6" },
radius: { sm: 2, md: 4, lg: 6 },
font: { body: "'Open Sans', sans-serif" },
},
// The component library's own internals, where a token cannot reach.
antd: { components: { Button: { controlHeight: 36 } } },
// Anything neither reaches — gradient borders, scrollbars, resets. Applied
// after the application's own global styles, so it wins on ties.
globalStyles: css`
[data-testid="app-header"] {
border-bottom: 1px solid transparent;
border-image: linear-gradient(90deg, #0084c0, #79d4f8) 1;
}
`,
// Fetched before the first render; a font arriving later reflows the page.
stylesheets: ["https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700"],
}Token names are fixed and a typo is a compile error; token values are not, so any colour or radius is accepted. The spacing scale is deliberately not overridable — it is a function every component calls, and replacing it would make layout unpredictable in ways no reviewer could anticipate.
A component library outside this project reads the Emotion theme in whatever
shape it was built against, which is unlikely to be the shape above. This
project nests its tokens (theme.color.bg); a library may expect flat keys
(theme.background). Nothing warns you: a library shipping no Emotion module
declaration leaves Theme widened only by this project, so TypeScript accepts
theme.background, and at runtime every colour resolves to undefined — the
components render, unstyled or invisibly low-contrast, with no error anywhere.
Supply both shapes by nesting a provider around the library's components rather than replacing the outer theme:
<ThemeProvider theme={(outer) => ({ ...outer, ...myLibraryTheme })}>The extension's own components then still see theme.color.*, and the library
sees the keys it expects. Verify it by reading a computed colour off a rendered
library component — not by checking that it mounted, which it will either way.
Contributing a nav item is enough when a product wants its pages listed alongside the application's. It is not enough when the navigation is a different shape — grouped sections, a collapse control, a logo, a footer — because those are properties of the sidebar itself and no number of items adds them.
shell: {
Sidebar: MySidebar, // receives { coreNavItems, extensionNavItems }
Header: MyHeader,
Layout: MyLayout, // replaces the whole shell; takes precedence over both
}Layout is for when the shell's arrangement differs rather than its regions.
Swapping the sidebar can only produce a variation on this application's
arrangement — header above, sidebar beside. A product whose logo lives in the
sidebar and which has no top bar needs a different arrangement, so it replaces
the layout. A replacement layout must render React Router's <Outlet />, or
no page appears at all.
Contributing an entry covers "this product has a page the application does not". This covers the other half: a product that lists the same pages differently, or that supplies its own version of a destination.
navOverrides: {
dashboard: { path: "/overview" }, // send a familiar entry somewhere else
substrate: { hidden: true }, // unlist it — the route still resolves
mcpServers: { label: "Tool Servers", order: 250 },
}Keys are the application's own nav keys and are type-checked. hidden only
unlists an entry — the route still resolves, so a typed URL never 404s because of
a navigation choice.
A path the application already claims is rejected by default: an accidental collision should always be an error. A contribution that declares what it replaces is allowed to take that path.
routes: [
{ path: "/", element: <ProductOverview />, replaces: "dashboard" },
]The named route is dropped and the contribution serves the path instead. Naming the route rather than matching its path means the replacement survives a path change, and that a genuine collision is still caught.
Reach for this only when the destination belongs to the product rather than being a variant of the application's page. Replacing a page the application maintains means its improvements stop arriving. Adding a point to the page is almost always better.
Identity is not styling, and it should not cost a layout replacement — a product happy with this application's chrome may still want its own mark on it.
branding: {
AppIcon: MyMark, // receives { collapsed }; supplied whole, like everything else
appName: "My Product", // used for the document title
faviconUrl: "/my-mark.svg", // the tab icon; a URL, since the browser loads it itself
}A replacement owns the region completely, including rendering the
application's own navigation — which is why it is handed coreNavItems rather
than keeping a copy that drifts as pages are added.
chatPartRenderers replaces the component that draws one part of a chat message.
Keys are the part's kind (text, file, tool_approval, ask_user), or the
dataKind for a data part (tool_call, tool_result, tool_not_run,
structured_output, unknown). A key you leave out keeps the core renderer.
The keys come from the part types themselves, so a new kind is overridable as
soon as it exists.
chatPartRenderers: {
// part is typed as a text part; role, messageId, taskId and sessionId describe the turn.
text: ({ part, role }) => (role === "agent" ? <MyProse text={part.text} /> : <p>{part.text}</p>),
structured_output: MyJsonViewer,
}The replacement owns the part completely, bubble included. Empty text parts are
not rendered at all, so a text renderer never sees a reply before it has words.
Wrapped around the application, outermost first — your query client, feature flags, telemetry, tenant context:
providers: [ExampleTenantProvider, ExampleTelemetryProvider],They sit inside the app's own theming, so they can read it, and outside the router, so they survive navigation. With several extensions installed the same rule applies one level up: the earlier extension's providers wrap the later one's.
An extension that needs configuring — an API root, a feature flag, an account id —
names a variable KAGENT_UI_EXTENSION_*. Three steps, and none of them touch this
application.
Read it with readEnv, which takes any key and a fallback:
import { readEnv } from "@/appExtensions";
const apiUrl = readEnv("KAGENT_UI_EXTENSION_EXAMPLE_API_URL", "https://api.example.test");Set it in a deployment through ui.env in the kagent chart, which is passed
through to the UI deployment verbatim:
# values.yaml
ui:
env:
- name: KAGENT_UI_EXTENSION_EXAMPLE_API_URL
value: https://api.example.testSet it locally in ui/.env, which is git-ignored — .env.example keeps a
section at the bottom for exactly this, so a branch that installs an extension
appends rather than editing the application's own settings above it:
KAGENT_UI_EXTENSION_EXAMPLE_API_URL=https://api.example.testThat is the whole mechanism, and nothing needs registering. The container's
entrypoint/ program copies every KAGENT_UI_EXTENSION_* variable out of the pod's environment
by prefix, and the dev server does the same from .env and your shell. Neither
enumerates the keys, so this application never learns what your settings mean and
needs no change when your extension grows one.
The values land on window.environmentVariables, which is what readEnv reads.
You should not have to think about that object — the Go entrypoint writes it — but
two consequences of it are worth knowing:
- A setting is a deployment's choice, not a build's. One image serves every
deployment and the values are rewritten on every container start, so changing one
needs a restart and a page reload, never a rebuild. Never reach for
import.meta.envfor a value an operator should be able to change. - A setting is readable synchronously, by the first module that runs, because the script tag carrying it is synchronous and precedes the app. A module-level constant can read one; anything awaited would be read after the app had started.
Do not add a VITE_ prefix. That prefix is Vite's envPrefix filter, which
decides what import.meta.env exposes to bundled code; it applies to build-time
variables and to nothing else. A prefixed name would no longer match the
KAGENT_UI_EXTENSION_ prefix the entrypoint and the dev server select on, so the value
would never arrive and readEnv would quietly return the fallback.
Points are added by this project, not by extensions — an extension cannot invent one, because the ID union is the contract.
- Add the ID to
EXTENSION_POINT_IDSinsrc/appExtensions/extensionPoints.ts. - Add an entry to
EXTENSION_POINT_RENDER_MODE(inlineunless clipping demandsportal). - If the point passes context, add its shape to
ExtensionPointPropsMap. - Mount it where it belongs:
<ExtensionSlot id="..." />, pluscontext={{ … }}for a context-carrying point.
import { ExtensionSlot } from "@/appExtensions";
// contextless — the id alone
<ExtensionSlot id="app_agents_agentsList_pageHeader_actions" />
// per-item context — required, and shape-checked
<ExtensionSlot
id="app_agents_agentsList_agentListItem_badge"
context={{ agentName, namespace }}
/>context is conditionally required: mandatory for points that declare a
contract, and not accepted for points that don't. Omitting it, misspelling the
ID, or passing an unexpected key are all compile errors rather than runtime
surprises.
Changing a point's context shape is a one-line edit to the props map; the compiler then lists every site that needs updating.
A slot with nothing configured renders null — no component, no wrapper element,
no whitespace. It is also safe with no provider above it, because the context
defaults to the empty install rather than throwing, so page unit tests need no
setup. Mount points permanently and let configuration decide.
The whole install is validated at startup and fails loudly rather than degrading
quietly. AppExtensionConfigError is raised for, within one extension:
- a slot naming an unknown extension point
- a form field targeting an unknown form ID
- a nav item key declared twice
- an agent rail item key declared twice, or one that is the application's own (
agentDetails,newChat) rather than anagentRailOverridesentry - a contributed route colliding with a core route, or declared twice
and for the collisions only the install as a whole can see:
- the same extension installed twice
- two extensions contributing the same route path
- two extensions contributing the same nav item key
- two extensions contributing the same agent rail item key
Two configs can each be perfectly valid and still be impossible to install together, and every one of those resolves silently and arbitrarily at runtime — the router takes the first match, React warns about the duplicate key and carries on. Turning them into a boot error is the point.
Every problem is collected before throwing, across every extension and prefixed with the id it came from, so one boot reports the whole list instead of making you fix them one restart at a time.
A silent no-op is the worst outcome here: an extension author cannot tell the difference between "my component is not mounted" and "I named the point wrong." The slot check is the load-bearing one — TypeScript already rejects an unknown point in typed config, but a config deserialised from JSON has no compiler in front of it.
- Both states. Verify your application with the extension installed and with nothing installed. The uninstalled path is what a default build ships, and it is easy to break by accident.
- Assert the mechanism, not the copy.
ExtensionSlotemits aextension-slot-<id>test id, so a test can assert that a component mounted at a point without depending on what it renders. - Watch out for identical contributions. If a per-item contribution renders the same text for every row, a text assertion passes even when the per-item context is broken. Assert something that actually varies per item.
- Fail on console errors. A component can satisfy every assertion while throwing in an effect. The e2e suite's shared fixture fails any test where the application logged an error; keep that habit.
- No automatic conflict resolution. Several extensions install together, but only where the composition rule is unambiguous — concatenate, or later wins. Anything genuinely contended (two extensions claiming a route path or a nav key) is a startup error, not something the framework picks a winner for.
- No runtime installation. The array is resolved at build time. There is no discovery, no plugin registry and no loading an extension from a URL; installing one is an edit and a rebuild.
- No partial configuration. Discussed above.
- Points exist only where the application declares them. If you need one that isn't there, add it upstream — that keeps the set of extension points a reviewed, documented surface rather than an accident of what happened to be reachable.