The administration panel of the Milpa PHP framework — the surface where a human leaves the house ready for the agent. Composed of Milpa Components, event-driven, and extended by declaration: every installed plugin adds its own section without the panel knowing its name.
Milpa is a framework a human administers and an agent operates. Everything the agent can do is a governed operation; everything the human sees is a projection of the same operations. The admin panel is the human's projection: install capabilities, read the routes and middleware every plugin declared, stand up the services the app needs, adjust configuration, read the ledgers of what the agent did.
Add one plugin to config/plugins.php and /milpa/admin exists:
return [
Milpa\Admin\AdminPlugin::class,
// …your plugins
];The panel opens with five sections of its own — Plugins (what the app boots, and the capabilities it can grow), Routes (every route the booted plugins declared, with handler and per-route middleware), Settings (what the app declared about the panel itself, key by key, with its source), Stack (every backing service the booted plugins declared they need — image, ports, environment with secrets masked, the declaring plugin — and whether its port answers on loopback) and Dev tools (the ledgers the agent writes, read-only) — and one more per plugin that declares one.
use Milpa\Admin\Section\{AdminSection, AdminSectionProvider};
final class InventoryPlugin implements PluginInterface, AdminSectionProvider
{
public function adminSections(): array
{
return [
// a dashboard primitive the panel already knows, with props
new AdminSection(id: 'stock', title: 'Stock', component: 'metric-card',
props: ['title' => 'Units in stock', 'value' => '1,204'], order: 30),
// or a component you bring yourself — definition + renderer, registered under `component`
new AdminSection(id: 'inventory', title: 'Inventory', component: 'inventory-table',
definition: new InventoryComponent($repo), renderer: new InventoryRenderer(), order: 31),
];
}
}The panel discovers implementers by instanceof over the booted plugins, at request time (boot order does not
matter), lists each section in the sidebar under its group, routes it at /milpa/admin/s/<id>, and renders it
inside the shell under a header that says who declared it. A duplicate id is a loud 500 naming both plugins — never
a silent "last one wins". One prop name is reserved: query — the shell hands every active section the request's
query params under props['query'], so a section can read its own (?session=<id>, a filter) without the shell
interpreting it; an AdminSection that declares props['query'] itself is refused at construction
(InvalidArgumentException) rather than silently overwritten on every request. The component names the panel
registers itself are reserved the same way — the dashboard primitives, admin-sidebar, admin-section-header: a
section may name one (component: 'metric-card'), but a section that brings its own definition under one is
refused (ReservedComponentException, a 500 that names the section and the name it tried to take) rather than
silently repainting every section that names it, the host's header included.
Two lifecycle pairs let another plugin extend a section or the shell without touching either:
admin.section.before_render / after_render (props, then HTML — both mutable) and
admin.shell.before_render / after_render (the composition and sidebar items, then HTML). A sidebar item a
subscriber adds names its group like a section does (app when it names none).
A section is a guest of the panel, and the panel is the host — it tells the guest what it knows and paints what
the guest cannot know about itself (greenhouse decisions/0210, sharpened by the first real guest, the Desktop's
Agent section).
The context. Every section's component mounts with a ComponentContext the shell fills — the same one every
component of the page gets, under that component's own id:
| field | value |
|---|---|
componentId |
milpa-admin-section-<id> |
principal |
the actor the gate authenticated — the id of the AuthContext a gate left under the request attribute milpa.auth (passkey:<credential> behind app-runtime's passkey gate) — or null when nobody is signed in. It is exactly what the topbar's signed in as … chip shows, so a guest that decides its state by the principal (the Desktop's signed-out vs live) always agrees with the topbar |
locale |
the language the page answers in — the app's admin.locale, or the request's ?lang= when the catalog carries it |
route |
the panel's mount point (admin.route, default /milpa/admin) — for a link back into the panel |
meta['gate'] |
the gate in effect, as the topbar chip names it: loopback | custom | passkey | open | fallback |
meta['section'] |
the id of the section the panel is showing |
meta['query'] |
the request's query params — how a declared view reads what props['query'] gives the narrow shape |
The props are the section's own props plus query (the request's query params). A guest reads the context and
its props; it never reads the request, and it never reads a cookie — the panel does not either.
The header. Above every section — the panel's own included — the host paints the section header: the title
(a catalog key translated, or the literal) and the attribution, read from the catalogue's record of which plugin's
adminSections() returned it, never from the section:
<header class="mui-page-header admin-section__header" id="milpa-admin-header" …>
<div class="mui-page-header__text">
<h1 class="mui-page-header__title">Agent</h1>
<span class="admin-section__declared" data-declared-by="Milpa\DesktopApp\DesktopAppPlugin">declared by DesktopAppPlugin</span>
</div>
</header>The short class name is shown (declared by DesktopAppPlugin · declarada por DesktopAppPlugin), the full class
travels in data-declared-by. A guest brings the region; the host brings the header, the sidebar and the topbar.
The sidebar. Sections list under their group, one heading per distinct value, in the house order — admin
(the panel's own: Plugins, Routes, Settings, Stack, Dev tools) → app (the default — a plugin's sections) →
agent (the agent's own surfaces: the Desktop's Agent section) → any other group name, alphabetically —
case-insensitively and in its own alphabet (año sorts among the a's, Zeta after beta). The headings come from
the catalog (ADMIN / APP / AGENT · ADMIN / APP / AGENTE); a group the catalog does not know is headed by its own
name uppercased in its own alphabet (año → AÑO). The glyph a section declares as icon is painted before its
label.
The order. Within a group, sections sort by order (lower first; ties break by id, alphabetically). The panel
opens on the first section in that same (order, id) order across every group, so the convention matters: the
panel's own take 10..40 (Plugins 10, Routes 20, Settings 25, Stack 30, Dev tools 40); a guest picks an order
after those — greenhouse decisions/0210 names 60 for the Desktop's Agent section — unless it means to be the
page the panel opens on. A guest at order 10 named agent would tie with Plugins, win the tie by id, and become the
front page.
new AdminSection(
id: 'reports', title: 'Reports', component: 'metric-card',
props: ['title' => 'Reports', 'value' => '12'],
order: 60, group: AdminSection::GROUP_APP, icon: '◈',
);One component is the narrow case. A plugin that brings its own UI declares a view — a tree of components, the
definitions and renderers it needs, the props per component and the signals the page must seed — and the panel
mounts it inline, in its own main, under its own header, with one runtime for the whole page (greenhouse
decisions/0211). No frame, no second document, no second Alpine: the panel's theme applies to the guest's region
— measured in a browser, flipping <html data-theme> repaints the guest's own surfaces, which an iframe could not
do. One endpoint over one registry also makes a RenderEffect from a host component able to repaint a guest's —
the mechanism is there; this slice did not exercise it. What the panel does not do yet is reach INTO the
region: the topbar's search is the inert dashboard-topbar primitive and nothing wires it to a guest, so
decisions/0211's S3-F1 falsifier («the topbar's search reaches the composer») is not delivered.
use Milpa\Admin\Section\AdminSection;
use Milpa\Admin\Section\DeclaredView;
AdminSection::ofView(
id: 'agent',
title: 'Agent',
view: new DeclaredView(
// Every ROOT is a Milpa element; ordinary HTML is allowed inside a component's node.
markup: '<milpa:desktop-tabs id="agent-tabs"/><milpa:desktop-conversation id="agent-conversation"/>',
definitions: ['desktop-tabs' => $tabs, 'desktop-conversation' => $conversation],
renderers: ['desktop-tabs' => $renderer, 'desktop-conversation' => $renderer],
props: ['desktop-conversation' => ['session' => $id]], // per component, merged UNDER the markup's attributes
signals: ['desktop.tab' => 'chat'], // seeded into the page's ONE signals tag
persist: ['desktop.tab'],
computed: ['desktop.summary' => ['template' => '{desktop.turns} turns']],
),
order: 60, group: AdminSection::GROUP_AGENT, icon: '◈',
);The constructor takes the same thing as view: — AdminSection::ofView() is the one-liner. A section declares a
view or a component, never both, and a view carries no section props (its props are per component).
What the host does with it.
- It registers the tree. Every name in
definitionsenters the panel's registry under a layer of its own, labelled with the section's id. Names the panel registers itself (metric-card,dashboard-*,admin-sidebar,admin-section-header) may be NAMED but never redefined (ReservedComponentException), and two sections binding one name to different definitions throwmilpa/live's ownComponentNameConflictExceptionnaming the component and both sections — the same rule everywhere: identity or a stateless class, never a structural compare. The renderer is held to that same rule by the panel (RendererConflictException): two sections may legitimately share one definition and still each bring their own renderer, and the renderer registry resolves the last one registered — so without a rule of its own the first section's surface would be repainted by the second's renderer, silently, in the one place whose whole point is that a collision is loud. Share the instance, or name your own component. Either refusal leaves the book as it was. - It emits one runtime. Every renderer that implements
DeclaresClientAssetscontributes its.cssand.js; the panel merges them (deduplicated by URL) and hands them toLiveBoot::html(), which emits — in<head>, after the panel's own stylesheets — the declared styles, the boot payload,milpa-live.js,milpa-live-remote.js, the guest modules in declared order, and Alpine last, eachdefer, each URL once. The panel hand-writes no runtime<script>tag. A guest never loads Alpine ormilpa-liveitself. - It seeds once.
#milpa-live-signals,#milpa-live-persistand#milpa-live-computedare emitted by the host and carry the panel's own seeds (admin.section,admin.gate,admin.locale) merged with the active view's. A key two declarers give different values is aSeedConflictExceptionnaming both and what each said; the same value twice is agreement, not a clash. - It contains failures. Each root of the view is compiled on its own: a component that throws while mounting or
rendering paints a small region inside its own node —
<div class="mui-alert mui-alert--warning admin-section__failure" data-failed-component="…">— and the rest of the view, the header, the sidebar and the chips all stand. Never a 500 for the whole panel. Containment is per root: a component nested inside another root fails with that root, so declare a surface you want contained as a root of your tree. - The lifecycle still fires.
admin.section.before_render/after_renderwrap the view exactly as they wrap a single component; with a view,SectionRender::$propsis the view's own component-name → props map.
The guest's client module binds its Alpine factory through the host's single runtime — the same path every component of the page uses:
// /plugins/agent/conversation.js — served by the plugin, declared by its renderer
MilpaLive.register('desktopConversation', function (config) { return { /* … */ }; });A component that ACTS needs somewhere to act. The panel mounts one live endpoint over the same registry the page compiled with, so one endpoint re-renders the host's components and every guest's.
The key is the app's, not the panel's — say it out loud. The wire verifies with the panel's key (admin.secret,
else live.secret, else one derived from this install), and every guest signs with its own package's key
(the guest's, else live.secret, else one derived from its install). So an envelope a guest's renderer signed
while painting a page comes back verified only when host and guest sign with one key — which is what declaring
a single house key does:
// config/app.php
'live' => ['secret' => getenv('MILPA_LIVE_SECRET') ?: ''],Measured on a fresh app with neither declared: posting the page's own desktop-tabs and desktop-conversation
envelopes to POST /milpa/admin/live → 400 {"ok":false,"error":"invalid_signature"} both times, while the
panel's own admin-sidebar envelope decodes fine (the positive control: the refusal is the key, not the wire).
With live.secret declared, the same desktop-tabs action → 200 with the surface re-rendered and a fresh
envelope. The refusal is loud and per call, never silent. The residue is on this side and named as such:
DeclaredView does not receive the panel's own codec, so a guest cannot sign with the host's key unless the app
says so.
POST /milpa/admin/live {action, payload, state, sessionId, csrfToken} → {ok, html, state, effects}
- Behind the same door. The route carries the panel's effective middleware stack — whatever gate the app declared, loopback-only by default. A wire outside the gate would be a hole: an unauthenticated caller could act on any mounted component of any section.
- The session is the page's.
LiveBoot::issue()mints it when the page is rendered; the runtime echoes it assessionIdin every request body. No cookie carries it. - The principal is the gate's. The endpoint adds no second policy: it names the actor the gate authenticated
(with the component scopes), so a component whose state is bound to a principal recognises its owner. Nobody
signed in is
null— the panel invents no identity.
A view mounted in the panel is served by the panel's door; the calls its modules make go to the guest's
routes and are judged by the guest's door. With both behind app-runtime's passkey gate, same origin and one
cookie, a guest's endpoints answer normally from inside the panel (measured: POST /agent reached its handler,
/desktop/* reached theirs). With the panel behind loopback and the guest behind passkey, a reader on
localhost opens the panel with no principal — and every call to the guest's own routes answers 401 while the
view is still mounted (measured: GET /desktop → 401, POST /agent → 401, POST /milpa/admin/live → reached,
because that one is behind the panel's door).
The panel cannot fix this and does not pretend to: ComponentContext::$principal and meta['gate'] are the
panel's, not the guest's. A guest whose own gate is stricter than the panel's must decide its state from those
two facts and say so in its region — greenhouse decisions/0210 §2 covers the common case (gate: passkey and no
principal → render the sign-in offer, not the view); the asymmetric case (the panel authenticated somebody through a
different door) is a known residue: the view mounts and its calls 401 inside a panel the reader was allowed
into. Neither package closes it in this slice.
A plugin that needs a container — a message hub, a database, a cache — says so as data, through
Milpa\Runtime\Stack\StackProviderInterface (milpa/runtime), instead of leaving it to a README:
use Milpa\Runtime\Stack\{StackProviderInterface, ServiceDeclaration, PortMapping, EnvVar};
final class HubPlugin implements PluginInterface, StackProviderInterface
{
public function services(): array
{
return [new ServiceDeclaration(
name: 'mercure', image: 'dunglas/mercure',
ports: [new PortMapping(container: 80, host: 3000)],
env: [
new EnvVar('SERVER_NAME', value: ':80'),
new EnvVar('MERCURE_PUBLISHER_JWT_KEY', configKey: 'desktop.mercure.publisher_key', secret: true),
],
summary: 'Pushes shell changes to the browser.',
)];
}
}The Stack section lists one card per declared service with its state — up when 127.0.0.1:<host port>
accepts a TCP connection, down when it refuses, unknown when no port is published — and
GET {route}/stack/compose.yml serves a compose file of every declared service (text/yaml, also linked from the
section). A secret is never shown and never inlined: the card masks it and the file projects ${NAME} for the
operator to supply; a configKey the app's config holds is inlined, one it lacks becomes ${NAME} too. The panel
starts nothing — declaring is the plugin's, running is the operator's (greenhouse decisions/0201).
Everything under the admin key of the app's config; every knob has a safe default.
| key | default | what it does |
|---|---|---|
admin.route |
/milpa/admin |
the mount point |
admin.locale |
en |
the panel's own copy — en or es |
admin.middleware |
[LoopbackOnlyMiddleware::class] |
PSR-15 classes attached to every panel route, outermost first. The default answers only to loopback; declare your passkey/scope gate here. Only a literally empty list [] opens the panel. |
admin.secret |
live.secret, else derived |
the HMAC secret that signs component state |
admin.title |
Milpa Admin |
the brand in the sidebar and the document title |
Assets (design tokens, bundle, the milpa/live-web client runtime and Alpine) are served by the panel itself under
{route}/assets/ — no build step, nothing copied into public/.
The Settings section reads the admin key back to you — route · locale · middleware · secret · title, each
with a default, config or rejected badge — and never writes it: changing configuration is a governed
operation, not a form. A value the app declared and the panel refused (a locale the catalog lacks, a route or title
of the wrong type, a gate that is not a list) is rejected, with the effective value in the row and what was
declared next to it — never painted default. The secret shows only where it came from (declared (admin.secret),
declared (live.secret), derived), never the value. A fresh app with no admin key sees five defaults and the
exact snippet to paste. Above the table, Panel preferences — theme (dark · light · system) and density, this
browser only, applied instantly and never sent to the server; and a language override, sent as ?lang= with each
request and never stored on the server — live in localStorage under milpa.admin.prefs.
The one rule the panel enforces rather than copies: only a literally empty list opens the panel; any misdeclaration
falls back to loopback-only and Settings says so. A non-string entry, an associative map, a value that is not a
list, an empty string, a class that does not exist, a class that is not a PSR-15 middleware — each makes every panel
route carry [LoopbackOnlyMiddleware::class], the strict gate, never an open one and never the half that loads, and
the panel names what it received (a danger badge on the row, a notice in Settings, gate: fallback in the topbar).
The topbar always shows the gate in effect (loopback · custom · passkey · open · fallback) and the locale. Any
panel page accepts ?lang=en|es to render in another catalog language for that request (greenhouse decisions/0204).
The panel does not authenticate anyone — it names its gate. The gate is PasskeyGateMiddleware, which
milpa/app-runtime's PasskeyPlugin registers under its own class name; it requires milpa/app-runtime >= 0.117,
and the ceremony it fronts — registration, sign-in, the session it mints, the store it checks — is that package's:
its README section ## Passkey gate is the reference. The
operator sequence, from a fresh app to a panel that opens only for your key:
- Declare the plugin and the relying party —
Milpa\AppRuntime\Web\PasskeyPlugin::classinconfig/plugins.php, and'passkey' => ['rpId' => 'localhost']inconfig/app.php(the host the browser is on; WebAuthn needshttps://orlocalhost). WithoutrpIdthe plugin mounts nothing. - Register the key — open
GET /webauthn/enroll, press Register with passkey, touch the key; the page prints the credential id. Registering grants nothing yet. - Root it in
config/identity.php— the file the running app reads and never writes. On a first run, a house that opted in with['bootstrap' => true]and holds norootedlist yet may root your signing key once, with the scopeidentity:enrollneeds overhttp/mcp(sealed after the first; on the CLI--signalone authorizes):Then declare the credential id as rooted —php bin/coa identity:bootstrap --scopes=identity:enroll --sign
identity:enrollrefuses a fingerprint this list does not hold:<?php return ['rooted' => ['<credential id>']];
- Enroll the credential with the panel's scope — a governed, signed operation:
php bin/coa identity:enroll --fingerprint=<credential id> --scopes=milpa.admin --sign
- Name the gate in
config/app.php:'admin' => ['middleware' => [\Milpa\AppRuntime\Web\PasskeyGateMiddleware::class]],
- Sign in —
GET /milpa/adminanswers302to/webauthn/signin?next=/milpa/admin; Continue with a passkey, touch, and the page posts to/webauthn/authenticate, which mints the session, sets the cookie and sends the browser back to the panel,200.
From step 5 on, identity replaces loopback-only: a request from the LAN is no longer refused for its address — it
gets 302 to sign-in (a JSON client 401) until it carries a live session, 403 when the session lacks the scope,
and the panel when it has it; loopback gets the same treatment. The gate reads the session cookie against its own
store and leaves the authenticated AuthContext on the request under the attribute milpa.auth. The panel reads
only that attribute — no cookie, no session id — and the topbar says signed in as <actor id> on every panel page,
the index and each section alike; when that class is the whole stack, Settings and the gate chip name it passkey
instead of custom, and the empty state's snippet offers the same admin key with the passkey gate as the
alternative to loopback-only. milpa/admin takes no dependency on milpa/auth or milpa/app-runtime for any of it
(greenhouse decisions/0206).
The Dev tools section reads what the agent already wrote and adds nothing of its own. The agent ledger is
resolved the way the agent operation resolves it: an EventStoreInterface registered in the container first,
then a registered SessionStore, then the file var/agent-sessions.jsonl under the app root — and the page says
which one it is reading (the class name or the path) under the sessions table and in the not-available notice.
The ledger is read once per page: replayAll() when a store gives it, the section's own tolerant line reader
when it is the file — the same one-JSON-object-per-line format FileEventStore writes, except that a line that
does not decode is counted («N line(s) could not be read») and skipped, never a failure that blanks every block.
From that one pass: the sessions — every stream that opened with session.started (a stream without one is
counted, not listed), reduced with SessionReducer, newest first, the table capped at the newest 50 with «N
older not listed» — each with its state derived from the stream (running · waiting · done, and interrupted
when the end fact says so or follows a closed answer window), its goal and mode, the provider's own token count
in/out (a call counts only when its usage carries an integer prompt_tokens or completion_tokens; not reported when none did — absent is not zero) and what it waits on, the pending question inline; the debt
signals (session.debt_signaled) grouped by their four real kinds, each glossed in one line and listed even at
zero; the evidence (session.evidence_recorded); and a log — the file the app declares under
admin.log, absolute or relative to the app root and confined to it (.. and symlinks resolved; outside is
a notice, not a read), tailed to its last 200 lines within its last 1 MiB. With no declaration the section says
so and invents no path; without a kernel no root is known, so a relative path is never resolved against the
working directory and nothing is read; a missing or unreadable file is a notice that names it, and never blanks
the other blocks. A session's id opens its timeline inside the section ({route}/s/devtools?session=<id>):
SessionProjector goes first and paints what it paints — turns, tool calls, todos, questions and answers, the goal
changing, the end — and only what it maps to null AND is in the section's bounded audit list is painted locally:
the opening, each debt signal with its context, the closure verdict, trial runs / promotions / discards, executed
operations (operation · executed_by/authorized_by · arguments digest) and paused/resumed sequences. The signed
state envelope of the section carries only {view, session}: the ledgers are a projection re-read on every
mount, never signed and sent to the browser. Every link inside the section carries ?lang= when the request
overrode the locale.
The coupling to milpa/agent is soft: without the package the section degrades to a notice naming it, and the
log block still reads. There is no form, no button and no command box anywhere in it — every mutation of the
house is a governed operation, and this section only reads (greenhouse decisions/0205). One rule it
introduced for every section: the request's query params reach the active section as props['query'], which
is why that prop name is reserved.
Every slice of this panel is proven on a fresh composer create-project milpa/framework app in the framework's
house (greenhouse decisions/0200, evidence/0514): a plugin written in the app, unknown to the panel, gets its
sections listed and rendered; an unknown section is 404; a non-loopback origin is 403; /desktop keeps serving.
The declared view of 0.12.0 was measured the same way, in a real Chrome, on an app behind app-runtime's passkey
gate (greenhouse decisions/0211, slice 3): a guest plugin written in the app declared a three-root view; the
panel mounted it inline with no iframe, emitted one milpa-live.js, one milpa-live-remote.js and
one Alpine (last), each declared file once, and merged the guest's seed into the page's single signals tag; the
guest's module bound its factory through MilpaLive.register in that one runtime; a click sent a signed envelope to
POST /milpa/admin/live and the counter went 1 → 2 → 3 with the guest's own renderer painting the answer; the third
root threw on purpose and painted its failure region while the rest of the view, the header, the sidebar and the
chips stood; the guest's CSS resolved the panel's design tokens, which an iframe could not do; the panel's own
five sections still rendered, one runtime each; the console was clean.
milpa/admin up to v0.5.2 was the panel of the original TeamX host — Symfony-shaped controllers, a gate wired to
that host's login, three shells. It never dispatched on a fresh framework app (greenhouse evidence/0513). From
0.6.0 the package is the framework's own panel, rebuilt on the same mould as the Desktop (milpa/desktop-app):
PSR-7 in and out, Milpa Components all the way down, declared not scanned. The old line stays citable at its tag.
See UPGRADING.md — every change is additive; the notes say what a guest and a shell subscriber gain, and what to check if you extended the shell.
composer install
vendor/bin/phpunit --testsuite Admin
vendor/bin/phpstan analyse src
vendor/bin/php-cs-fixer fix --dry-run --diffApache-2.0 — (c) Rodrigo Vicente - TeamX Agency. See LICENSE and NOTICE.
Milpa is designed, built, and maintained by Rodrigo Vicente - TeamX Agency.