Skip to content

feat(metadata): implement v1.0 extension metadata APIs across SDKs - #3757

Merged
gspencergoog merged 10 commits into
a2ui-project:mainfrom
gspencergoog:feat/metadata-api
Oct 30, 2026
Merged

gspencergoog merged 10 commits into
a2ui-project:mainfrom
gspencergoog:feat/metadata-api

Conversation

@gspencergoog

Copy link
Copy Markdown
Collaborator

Summary

Implements A2UI v1.0 extension metadata APIs across TypeScript, Dart, Python, and Swift. Also adds shared conformance suites, Express extensions= authoring, and updated specification and blueprints.

Resolves #3708
Resolves #3468
Resolves #3469
Resolves #3560
Resolves #3103

Reviewer Focus

  • Check metadata validation, deep-freezing, and empty-envelope normalization in typescript/core/web_core/src/common/metadata.ts, dart/a2ui_core/lib/src/core/metadata.dart, python/a2ui_core/src/a2ui/core/common/metadata.py, and swift/core/Sources/A2UICore/Validation/MetadataValidator.swift.
  • Check that ComponentNode.metadata is decoupled from ComponentNode.props in NodeResolver across TypeScript, Dart, Python, and Swift. Metadata-only updates do not re-render components that read only props.
  • Inspect action metadata merging (mergeActionMetadata) in SurfaceModel.dispatchAction and GenericBinder, where call-site keys take precedence over keys on the action payload.
  • Check conditional input binding on Angular's ComponentHostComponent (typescript/renderers/angular/src/core/component-host.component.ts). It inspects reflectComponentType before setting metadata to avoid NG0303 errors on plain custom components.
  • Generated Python schema file (python/a2ui_core/src/a2ui/core/schema/v1_0/renderer_to_agent.py) and CHANGELOG.md entries can be skimmed quickly.

Changes

  • Added UAX 31 key checks (validateMetadata), deep-frozen snapshots (cloneMetadata), and structural equality (metadataEquals / sameMetadata). Empty envelopes ({} and {extensions: {}}) normalize to nullish values.
  • Added symmetric metadata getters and setters (model.metadata = ...), extensions views, setExtension, removeExtension, and onMetadataChanged streams on SurfaceModel and ComponentModel. Swift uses setMetadata(_:) throws.
  • Added atomic applyUpdate / apply_update handling during updateComponents so MessageProcessor updates properties and metadata together before emitting change notifications.
  • Exposed an isolated read-only metadata signal on ComponentNode in TypeScript and Dart, ComponentNode.metadata and on_metadata_changed in Python, and reactive Node.metadata in Swift alongside SurfaceModel.metadataSignal.
  • Wired reactive component and surface metadata into @a2ui/web_core universal elements (A2uiController and A2uiLitElement), @a2ui/react, @a2ui/angular, a2ui_flutter, and A2UISwiftUI (@Environment(\.a2uiSurfaceMetadata)).
  • Preserved ComponentDefinition.metadata on ComponentApi across Catalog.fromJson and Catalog.toJson in TypeScript, Dart, and Python. Updated Pydantic codegen to emit ActionMetadata instead of colliding with Extensions.
  • Added call-site ActionDispatchOptions (metadata) to NodeAction, ComponentContext.dispatchAction, and SurfaceModel.dispatchAction across all four Core SDKs.
  • Added Python authoring helpers (ComponentBuilderNode.metadata, with_extension, CreateSurface(extensions=...), and set_surface_extensions). Added reserved extensions= keyword compilation and decompilation in v1.0 A2UI Express.
  • Added cross-SDK conformance coverage in conformance/core/message_processor_v1_0.yaml, conformance/core/node_resolution.yaml, conformance/core/actions.yaml, conformance/core/catalog.yaml, and conformance/agent/express/compiler.yaml.

Impact & Risks

  • In Python a2ui_core, ComponentModel now strips envelope metadata out of component.properties into component.metadata for v1.0 components, matching TypeScript, Dart, and Swift.
  • Regenerating v1.0 Pydantic models changes A2uiRendererAction.metadata from Extensions | None to ActionMetadata | None.
  • Components that read only node.props incur zero extra renders when component metadata changes because metadata subscriptions are lazy across React, Lit, Angular, and Flutter.

Testing

  1. Run TypeScript core, renderer, and agent unit and conformance tests:
    • yarn --cwd typescript/core/web_core test and yarn --cwd typescript/core/web_core test:conformance
    • yarn --cwd typescript/renderers/angular test
    • yarn --cwd typescript/renderers/react test
    • yarn --cwd typescript/agents/a2ui_agent test
  2. Run Dart and Flutter static analysis and test suites:
    • cd dart/a2ui_core && dart analyze && dart test
    • cd dart/a2ui_agent && dart analyze && dart test
    • cd dart/a2ui_flutter && flutter analyze && flutter test
  3. Run Python core and agent test suites:
    • cd python/a2ui_core && uv run pytest
    • cd python/a2ui_agent && uv run pytest
  4. Run Swift core and SwiftUI test suites on macOS or Linux with Swift installed:
    • cd swift/core && swift test
    • cd swift/swiftui && swift test

gemini-code-assist[bot]

This comment was marked as resolved.

Adds UAX 31 extension key validation, deep-frozen metadata snapshots, symmetric metadata property getters and setters, reactive ComponentNode and SurfaceModel metadata signals, outbound action metadata merging, catalog ComponentApi metadata preservation, Express extensions= compilation/decompilation, and cross-SDK conformance suites across TypeScript, Dart, Python, and Swift.

Resolves a2ui-project#3708
Resolves a2ui-project#3468
Resolves a2ui-project#3469
Resolves a2ui-project#3560
Resolves a2ui-project#3103

Signed-off-by: Greg Spencer <gspencer@google.com>
@gspencergoog
gspencergoog marked this pull request as ready for review October 29, 2026 21:06
…ess map matching

Guard inspect.signature in ComponentContext.dispatch_action against callables
that lack signature metadata and support keyword-only metadata or **kwargs
dispatch callbacks alongside positional ones. Pattern-match Map<Object?, Object?>
in Dart ExpressCompiler._extensionsMap so dynamically typed maps validate cleanly.
Resolve conflicts in @a2ui/web_core with PR a2ui-project#3579 (DataContext.resolveAction delegation, MessageErrorMapper, and node-resolution conformance steps) while preserving ActionDispatchOptions and extension metadata propagation.
@gspencergoog
gspencergoog requested a review from nan-yu October 29, 2026 22:31

@nan-yu nan-yu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed PR #3757 (47ffd873). See inline comments for high and medium severity findings.

🤖 Agent-generated

Resolve conflicts with a2ui-project#3712 (incremental Express streaming) in the Dart and TypeScript Express compilers and the compiler conformance suite. Carry surface(extensions=...) through the new buildUnits/_build_units and buildCreateMessages paths, including the streaming create path, and thread the target version into unit building in Python and TypeScript so surface() extension validation still has it.

@nan-yu nan-yu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing all 10 findings from the earlier review across the React adapter, Python/Swift/Dart Core SDKs, and Express compilers! Left two optional follow-up notes on incremental Express streaming (ExpressScopeSession) from the #3712 merge. LGTM!

🤖 Agent-generated

@gspencergoog
gspencergoog enabled auto-merge (squash) October 30, 2026 18:23
@gspencergoog
gspencergoog merged commit 9055207 into a2ui-project:main Oct 30, 2026
55 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in A2UI Oct 30, 2026
@gspencergoog

Copy link
Copy Markdown
Collaborator Author

FYI, here's a user guide for how to use these APIs:

Details

Using Extension Metadata in A2UI v1.0

In A2UI v1.0, protocol schemas reject unknown top-level fields. When you need to attach non-visual data such as telemetry tags, access control policies, or host hints, use the metadata envelope ({ "extensions": { ... } }).

You can attach and read extension metadata in four places:

  1. Surfaces (createSurface.metadata)
  2. Component instances (component.metadata)
  3. Outbound renderer actions (action.metadata)
  4. Catalog component definitions (ComponentDefinition.metadata)

Core Rules

Every SDK enforces three rules when reading or writing metadata:

  1. Extension keys inside metadata.extensions must be valid Unicode identifiers (UAX 31, such as agui_theme or com_acme_trace). Invalid keys throw A2uiValidationError.
  2. Models store deep-frozen copies of metadata. Mutating an object after passing it in does not change model state.
  3. Empty envelopes ({} or { "extensions": {} }) normalize to undefined, null, None, or nil. Setting an identical metadata value is a no-op and emits no change events.

1. Authoring Metadata from an Agent

A2UI Express DSL

In v1.0 A2UI Express, pass the extensions= keyword argument to surface(...) or to any component call.

surface("main", extensions={surfaceTheme: "dark", traceId: "req_42"})

header = Text("Welcome", extensions={role: "banner"})
root = Column([header])

[!NOTE]
surface(..., extensions=...) is only valid on a surface-creating scope that defines root. If a catalog schema defines a property named extensions on a component, the argument maps to that property instead of envelope metadata.

Python Agent Builders (a2ui_agent and a2ui_core)

In Python, pass extensions={...} when constructing a CreateSurface or a ComponentBuilderNode, or mutate a node in place:

from a2ui.agent import ComponentBuilder, CreateSurface, set_surface_extensions

b = ComponentBuilder()
title = b.Text("Quarterly Report", extensions={"analytics_id": "q4_title"})
title.with_extension("priority", "high")

# Replace the full metadata envelope on the builder node:
title.metadata = {"extensions": {"analytics_id": "q4_title", "priority": "high"}}

msg = CreateSurface(
    surface_id="report",
    root=b.Column([title]),
    extensions={"surfaceTheme": "compact"},
).build()

# Stamp or merge surface extensions onto existing message dicts:
set_surface_extensions([msg], surface_id="report", extensions={"tenant": "acme"})

Reading Action Metadata on the Agent

When a renderer dispatches an action with metadata, read action.metadata.extensions from the parsed A2uiRendererAction:

if action.metadata and action.metadata.extensions:
    trace_id = action.metadata.extensions.get("traceId")

2. Managing Metadata on SurfaceModel and ComponentModel

SurfaceModel and ComponentModel share the same metadata interface across TypeScript, Dart, Python, and Swift. You can replace the entire envelope, update a single extension key, remove a key, or subscribe to changes.

TypeScript (@a2ui/web_core)

// Read current metadata or extensions map
const meta = surface.metadata;
const theme = surface.extensions?.surfaceTheme;

// Replace the full envelope (validates UAX 31 keys and deep-freezes)
surface.metadata = { extensions: { surfaceTheme: 'dark' } };

// Set or remove a single extension key
surface.setExtension('traceId', 'abc_123');
surface.removeExtension('traceId');

// Same operations on a component model
const comp = surface.componentsModel.get('header')!;
comp.setExtension('pinned', true);

// Subscribe to metadata changes
const sub = comp.onMetadataChanged.subscribe((next) => {
  console.log('Updated metadata:', next);
});
sub.unsubscribe();

Dart (a2ui_core)

final meta = surface.metadata;
final theme = surface.extensions?['surfaceTheme'];

surface.metadata = {
  'extensions': {'surfaceTheme': 'dark'},
};
surface.setExtension('traceId', 'abc_123');
surface.removeExtension('traceId');

final sub = surface.onMetadataChanged.addListener((next) {
  print('Updated surface metadata: $next');
});
sub.cancel();

Python (a2ui_core)

meta = surface.metadata
theme = (surface.extensions or {}).get("surfaceTheme")

surface.metadata = {"extensions": {"surfaceTheme": "dark"}}
surface.set_extension("traceId", "abc_123")
surface.remove_extension("traceId")

sub = surface.on_metadata_changed.subscribe(
    lambda next_meta: print("Updated:", next_meta)
)
sub.unsubscribe()

Swift (A2UICore)

Because Swift property setters cannot throw, use setMetadata(_:), setExtension(_:value:), and removeExtension(_:):

// On SurfaceViewModel (@MainActor)
try viewModel.setMetadata(["extensions": .object(["surfaceTheme": .string("dark")])])
try viewModel.setExtension("traceId", value: .string("abc_123"))
viewModel.removeExtension("traceId")

// On a component inside SurfaceComponentsModel
try viewModel.components.setExtension("pinned", value: .bool(true), forComponentID: "header")
viewModel.components.removeExtension("pinned", forComponentID: "header")

3. Reading Reactive Metadata in UI Components

ComponentNode.metadata is decoupled from ComponentNode.props. Updating component metadata only re-renders views that actually read metadata, leaving standard components untouched.

React (@a2ui/react)

Custom catalog components receive metadata on their props (lazily subscribed on first read). You can also subscribe directly via useSignalValue:

import { useA2uiSurface, useSignalValue } from '@a2ui/react';

export function CustomBanner({ text, metadata }: { text: string; metadata?: A2uiMetadata }) {
  const surface = useA2uiSurface();
  const surfaceMeta = useSignalValue(surface.metadataSignal);
  const role = metadata?.extensions?.role;
  const theme = surfaceMeta?.extensions?.surfaceTheme;

  return <div data-role={String(role)} data-theme={String(theme)}>{text}</div>;
}

Lit and Universal Elements (@a2ui/lit, @a2ui/web_core)

Read this.controller.metadata (or this.metadata on A2uiLitElement) inside render(). Reading the getter registers a reactive signal dependency for that element:

render() {
  const role = this.metadata?.extensions?.role;
  const surfaceMeta = getValue(this.context.surface.metadataSignal);
  return html`<div data-role=${role ?? ''}>${this.props.text}</div>`;
}

Angular (@a2ui/angular)

Declare an optional metadata signal input on your custom component. ComponentHostComponent binds metadata automatically when the input exists:

@Component({ ... })
export class CustomBannerComponent extends CatalogComponent {
  readonly metadata = input<A2uiMetadata | undefined>(undefined);
}

Flutter (a2ui_flutter)

Read node.metadata.value inside your CatalogComponentBuilder or read SurfaceScope.of(context).metadataSignal.value. NodeView tracks signal reads during build and rebuilds the widget when node.metadata changes:

Widget buildBanner(
  BuildContext context,
  ComponentNode node,
  Map<String, Object?> props,
  Widget Function(String) buildChild,
) {
  final compMeta = node.metadata.value;
  final surfaceMeta = SurfaceScope.of(context).metadataSignal.value;
  return Text('${props['text']} (${compMeta?['extensions']})');
}

SwiftUI (A2UISwiftUI)

Read node.metadata on the resolved Node and read @Environment(\.a2uiSurfaceMetadata) for ambient surface metadata:

struct BannerView: View {
  @ObservedObject var node: Node
  @Environment(\.a2uiSurfaceMetadata) private var surfaceMetadata

  var body: some View {
    let role = node.metadata?["extensions"]?.objectValue?["role"]?.stringValue
    Text(role ?? "default")
  }
}

4. Attaching Metadata to Outbound Actions

You can attach metadata to an action either on the action definition itself or at dispatch time. When both supply metadata.extensions, call-site extension keys override action-definition keys with the same name.

// TypeScript: pass ActionDispatchOptions to a resolved NodeAction
onClick={() => props.action?.({
  metadata: { extensions: { clientTimestamp: Date.now() } },
})}

// Or dispatch directly through ComponentContext or SurfaceModel
context.dispatchAction(actionPayload, {
  extensions: { sourceGesture: 'double_tap' },
});
// Dart: pass ActionDispatchOptions to a bound action closure
onPressed: () => action(
  const ActionDispatchOptions(
    metadata: {
      'extensions': {'sourceGesture': 'double_tap'},
    },
  ),
),
# Python: pass metadata to dispatch_action
await surface.dispatch_action(
    action_payload,
    source_component_id="btn_submit",
    metadata={"extensions": {"sourceGesture": "keyboard"}},
)
// Swift: invoke a ResolvedAction with call-site metadata
action(metadata: ["extensions": .object(["sourceGesture": .string("tap")])])

5. Static Catalog Component Metadata

Catalog authors can declare static metadata.extensions on individual component definitions in catalog.json. Catalog.fromJson validates the extension keys and exposes the metadata on ComponentApi.metadata (node.impl?.metadata at render time), while Catalog.toJson preserves it on serialization.

{
  "components": {
    "CustomChart": {
      "metadata": {
        "extensions": {
          "category": "visualization",
          "requiresWebGL": true
        }
      },
      "type": "object",
      "properties": {
        "component": { "const": "CustomChart" }
      },
      "required": ["component"]
    }
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment