Magic integration
MagicTapirIntegration is the glue layer that registers Magic-aware snapshot enrichers with tapir.
On this page
It sits between the magic Flutter framework (Laravel-inspired primitives like MagicForm,
MagicRouter, Gate, Auth, Echo) and the tapir snapshot pipeline. It registers a fixed
set of TapirSnapshotEnricher callbacks against TapirPlugin.enrichers, so a tapir:snap
(or tapir_snap MCP) taken with includeEnrichers carries Magic-aware annotations
alongside the standard Semantics tree.
MagicTapirIntegration ships in the magic_devtools package, not in magic
core. Add it as a dev_dependency (it is debug-only; release builds tree-shake
the !kReleaseMode branch that calls install()):
dev_dependencies:
magic_devtools: ^<version>
This document covers the behavioural enrichers most likely to drive an
agent's reasoning loop. The others (magicFormEnricher, magicNavigationEnricher,
magicControllerFlagsEnricher, magicRouteParamsEnricher,
magicEchoConnectionEnricher, magicGateResultsAllEnricher,
magicRecentHttpEnricher, magicRecentLogsEnricher,
magicRecentExceptionsEnricher) surface form fields, the active route,
controller flags, route parameters, broadcast connection state, recent
gate results, and the crumbs HTTP / log / exception ring buffers; read
the dartdoc on MagicTapirIntegration for those.
When to call install#
MagicTapirIntegration.install() must run after Magic.init() has
booted the container (the enrichers read from Magic.controllers,
MagicRouter.instance, Gate.manager, and Auth.user(), all of which
require the service providers to be live). It must also run after
TapirPlugin.install(), because the TapirPlugin.enrichers list is the
target the integration mutates.
The canonical debug-only host integration (MagicTapirIntegration comes from
the magic_devtools dev_dependency):
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:magic/magic.dart';
import 'package:magic_devtools/tapir.dart'; // from the magic_devtools dev_dependency
import 'package:tapir/tapir.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (!kReleaseMode) TapirPlugin.install();
await Magic.init();
if (!kReleaseMode) MagicTapirIntegration.install();
runApp(const MagicApplication());
}
package:magic_devtools/tapir.dart exists only in a magic_devtools release built on
tapir; an older release has no tapir integration yet, and the import above does not
compile against it. Upgrade magic_devtools first. dart run tapir tapir:install writes
these lines only when the resolved magic_devtools ships tapir.dart; otherwise it skips
them and warns, and tapir:doctor says what to upgrade.
install() is idempotent; calling it twice in the same isolate is a no-op
after the first call, matching TapirPlugin.install() semantics. Release
builds tree-shake both !kReleaseMode branches. Installing tapir before Magic.init() keeps
the driver live while Magic boots.
The core enrichers#
magicControllerEnricher#
Emits magicControllerState: <ControllerClass>.<rxStatus> for the first
MagicStateMixin-bearing controller registered via Magic.put. The status
is the enum name of the controller's rxStatus.type (success, loading,
error, or empty). Returns null when no MagicStateMixin controller is
registered, so a guest-only or pre-controller view collapses cleanly.
magicFormErrorsEnricher#
Emits magicFormErrors: <field>="<text>",... for elements under a
MagicForm whose controller carries server-side ValidatesRequests errors
that match the form's own field set. Cross-form leak is guarded by
intersecting the controller's validationErrors.keys with the form's
fieldNames; messages longer than 80 characters are truncated to a
77-character prefix followed by .... Returns null when the controller
has no errors, no ValidatesRequests mixin, or no errors matching the
form's fields.
magicGateResultEnricher#
Emits magicGateResult: <ability>.<allowed|denied> for the most recently
cached GateResult in Gate.manager. The cache is populated transparently
by every Gate.allows / Gate.denies call, so an agent reading the
snapshot can confirm which authorization check ran last and what it
returned. Returns null when no check has run since the last
Gate.manager.flush().
magicMiddlewareEnricher#
Emits magicMiddleware: <name1,name2> for the active route's resolved
middlewares. Reads MagicRouter.instance.currentRoute.middlewares and
labels each entry by class toString() for MagicMiddleware instances or
by the raw string for kernel-aliased middlewares. Returns null when no
route is active or the route has zero middlewares.
magicAuthUserEnricher#
Emits magicAuthUser: <id>[:<displayName>] for the authenticated user
surfaced by Auth.user(). Falls back to magicAuthUser: <id> (no trailing
colon) when display_name is null, missing, or empty. Returns null when
the session is a guest.
Example snapshot output#
A typical snapshot fragment for a MonitorListView rendered inside a
MagicForm with one validation error and a recently checked monitors.view
ability:
- ref: e3
role: button
label: "Create Monitor"
bounds: 16,128,160,40
magicRoute: /monitors
magicControllerState: MonitorController.success
magicGateResult: monitors.create.allowed
magicMiddleware: EnsureAuthenticated,VerifyTeamMembership
magicAuthUser: 4f9a-2b1c:Anilcan Cakir
- ref: e4
role: textField
label: "Name"
bounds: 16,200,328,48
magicFormField: name
magicFormErrors: name="The name field is required."
The enrichers populate the lines under each ref in the order
they were registered. The dispatcher iterates TapirPlugin.enrichers in
insertion order and writes every non-null fragment under the ref. Nothing
deduplicates keys: if two enrichers emit the same key, the snapshot carries
both lines, so keep enricher keys distinct.
Test-only reset#
MagicTapirIntegration.resetForTesting() removes every enricher from
TapirPlugin.enrichers, cancels the internal Echo connection-state
subscription, and clears the idempotency guard. Call it in tearDown so
consecutive widget tests start with a clean enricher chain.
Frozen contract#
Every Magic enricher honours the TapirSnapshotEnricher typedef contract:
synchronous, stateless, never retains the Element across calls, returns
null when the element is not relevant. The typedef is frozen; see
enricher-authoring.md for the full typedef and authoring guide.
Related Documentation#
- enricher-authoring.md: the typedef, the four-clause contract and how to write your own enricher.
- wind-integration.md: the
wind:block that tapir emits ahead of these enrichers.