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 in the declared order, routes it at /milpa/admin/s/<id>, and renders
it inside the shell. 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.
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 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.
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.
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.