Zum Inhalt springen

UIAP Agent Integration Guide

FeldWert
StatusInformative (nicht normativ)
Version0.1
Datum2026-03-27
Abhängigkeiten[UIAP-CORE], [UIAP-CAP], [UIAP-WEB], [UIAP-ACTION], [UIAP-POLICY], [UIAP-WORKFLOW]
EditorenPatrick

Companion-Dokument. Informativ, nicht normativ.

UIAP beschreibt Transport, Capability Model, Web-Profil, Runtime, Policy und Workflows, lässt aber bewusst offen, wie ein Agent intern plant. Genau an dieser Stelle bauen sonst alle ihre eigene Brücke. Dieser Guide beschreibt eine pragmatische Integrationsschicht für einen LLM-basierten Agenten, der UIAP-Artefakte ernst nimmt, ohne das Modell zur Quelle der Wahrheit zu machen.

Die Grundidee ist einfach:

  • Der vollständige UIAP-Zustand lebt außerhalb des LLM in einem lokalen State Store.
  • Das LLM sieht nur eine redigierte, komprimierte und schrittrelevante Sicht auf diesen Zustand.
  • Capability, Policy und Workflow werden nicht als lose Doku in den Prompt gekippt, sondern in eigene Laufzeitkomponenten übersetzt.
  • Jede schreibende oder nicht triviale Aktion läuft durch Policy-Preflight, Action Runtime und Beobachtung/Verifikation.
  • Erfolg wird nie „geglaubt“, sondern aus action.result, web.state.delta und web.signal abgeleitet.

Wenn Authoring-Bundles vorhanden sind, sollte die Agent-Integration idealerweise das kompilierte Bundle konsumieren, nicht lose Manifeste. Das Bundle ist die laufzeitnahe Quelle für effektive Actions, Policies und Workflows.

Hinweis: Die Beispiele in diesem Guide verwenden durchgängig den Namespace uiap.policy.*.


Eine vernünftige Agent-Host-Schicht trennt sechs Verantwortungen:

  1. Session/Transport Handshake, Capability-Fetch, Observe-Subscription, Action-Requests.

  2. State Store Hält den letzten vollständigen PageGraph, wendet Deltas an, puffert Signale und Revisionen.

  3. Policy Gateway Evaluiert Policy vor Aktionen und erzeugt zusätzlich eine redigierte Modell-Sicht auf Snapshot, Signale und Return Values.

  4. Workflow Index Matcht verfügbare Workflows gegen Ziel, Route, Rollen, Grants und Modus.

  5. Tool Registry Übersetzt ActionDescriptor[] in LLM-Tools oder in eine kleinere, kontextabhängige Tool-Auswahl.

  6. Context Builder Baut aus rohem UIAP-Zustand eine kompakte Planner-Sicht für das LLM.

Ein minimales Laufzeitmodell sieht so aus:

interface AgentRuntime {
state: StateStore;
policy: PolicyGateway;
workflows: WorkflowIndex;
tools: ToolRegistry;
context: ContextBuilder;
planner: Planner;
}
interface PlannerInput {
goal: string;
policyHints: PolicyHints;
context: PlanningContext;
workflows: WorkflowRecipe[];
tools: LLMTool[];
}

Wichtig ist die Trennung zwischen rohem Zustand und Prompt-Zustand. Das LLM darf nie der einzige Speicher für Route, Scope, Revision oder letzte Resultate sein. Modelle vergessen, verwechseln und erfinden gerne. Wirklich rührend, aber unerquicklich.


Der PageGraph ist bereits ein semantisch reduzierter Web-Zustand und kein Vollabbild des DOM. Trotzdem kann er groß werden. Die empfohlene Strategie ist deshalb nicht „alles ins Modell“, sondern ein zweistufiges Modell:

  • State Store hält den vollständigen, letzten bekannten Graphen.
  • Context Builder macht daraus pro Turn eine kleinere Planner-Sicht.

Der Planner braucht in der Regel nicht die volle Rohstruktur. Für Planung sind vor allem diese Felder wichtig:

Primär für PlanungPrimär für Ausführung / Recovery
route.routeId, pathname, titledocumentId
scopeId, stableId, role, namebbox, zIndexHint
Zustände wie visible, enabled, focused, editable, required, invalid, open, busy, loadingtargetHints.runtime.css, targetHints.runtime.xpath
affordances, supportedActionssemantics.attached, inViewport, obscured, stable
risk, success, aktuelle Signale, FokusshadowHostId, framePath
annotierte Bedeutung wie meaning oder defaultActionlow-level Viewport-/Scroll-Details

Faustregel:

  • Planung arbeitet mit stabiler Semantik.
  • Ausführung arbeitet mit Zielauflösung, Actionability und Runtime-Hints.
  • CSS/XPath gehören fast nie in den LLM-Planungskontext. Sie sind technische Fallbacks, keine Identität.

Für das LLM reicht meistens ein Objekt wie dieses:

type PlanningElement = {
stableId?: string;
scopeId?: string;
role: string;
name?: string;
meaning?: string;
defaultAction?: string;
state: Record<string, unknown>;
supportedActions: string[];
risk?: {
level: "safe" | "confirm" | "blocked";
tags?: string[];
};
success?: Array<Record<string, unknown>>;
confidence?: "high" | "medium" | "low";
};
interface PlanningContext {
revision: string;
route?: {
routeId?: string;
pathname?: string;
title?: string;
};
activeScopes: Array<{
scopeId: string;
kind: string;
stableId?: string;
name?: string;
parentScopeId?: string;
}>;
focus?: {
stableId?: string;
role?: string;
name?: string;
};
candidateElements: PlanningElement[];
recentSignals: Array<{
kind: string;
level?: string;
text?: string;
scopeId?: string;
}>;
}

Eine brauchbare Heuristik für candidateElements priorisiert:

  • Elemente in offenen Dialogen, Drawern, Popovers
  • Elemente im fokussierten Scope
  • Elemente mit stableId
  • Elemente mit defaultAction oder Domain-Action in supportedActions
  • invalid, required, busy, open oder selected Zustände
  • Elemente mit risk.level = confirm|blocked
  • sichtbare Feedback-Elemente wie Toast, Alert, Status

Und sie depriorisiert:

  • rein dekorative Dinge
  • wiederholte Listeneinträge ohne aktuelle Relevanz
  • große Mengen von Text ohne Steuerungsbezug
  • offscreen oder verdeckte Targets, solange sie für den aktuellen Schritt nicht gebraucht werden

Eine simple Builder-Funktion kann so aussehen:

function buildPlanningContext(graph: PageGraph, maxElements = 30): PlanningContext {
const activeScopeIds = pickActiveScopes(graph);
const candidateElements = graph.elements
.filter((el) => isRelevantForPlanning(el, activeScopeIds))
.map((el) => ({
stableId: el.stableId,
scopeId: el.scopeId,
role: el.role,
name: el.name,
meaning: el.targetHints?.annotations?.meaning,
defaultAction: el.targetHints?.annotations?.defaultAction,
state: pickState(el.state, [
"visible",
"enabled",
"focused",
"editable",
"required",
"invalid",
"selected",
"expanded",
"open",
"busy",
"loading"
]),
supportedActions: el.supportedActions,
risk: el.risk,
success: el.success,
confidence: deriveConfidence(el)
}))
.sort((a, b) => scorePlanningElement(b) - scorePlanningElement(a))
.slice(0, maxElements);
return {
revision: graph.revision,
route: graph.route
? {
routeId: graph.route.routeId,
pathname: graph.route.pathname,
title: graph.route.title
}
: undefined,
activeScopes: pickScopes(graph, activeScopeIds).map((scope) => ({
scopeId: scope.scopeId,
kind: scope.kind,
stableId: scope.stableId,
name: scope.name,
parentScopeId: scope.parentScopeId
})),
focus: resolveFocus(graph),
candidateElements,
recentSignals: (graph.signals ?? []).slice(-8).map((sig) => ({
kind: sig.kind,
level: sig.level,
text: sig.text,
scopeId: sig.scopeId
}))
};
}

1.4 Planungskontext und Ausführungskontext trennen

Abschnitt betitelt „1.4 Planungskontext und Ausführungskontext trennen“

Das Modell sollte nicht sofort alle Ausführungsdetails sehen. Für den nächsten Schritt reicht fast immer die semantische Form:

{
"route": { "routeId": "videos.new", "title": "Neues Video" },
"activeScopes": [
{ "scopeId": "scope_form", "kind": "form", "stableId": "video.create.form", "name": "Video erstellen" }
],
"focus": { "stableId": "video.title", "role": "textbox", "name": "Titel" },
"candidateElements": [
{
"stableId": "video.title",
"role": "textbox",
"name": "Titel",
"state": { "enabled": true, "required": true },
"supportedActions": ["ui.focus", "ui.enterText", "ui.clearText"]
},
{
"stableId": "video.submit",
"role": "button",
"name": "Video erstellen",
"state": { "enabled": true },
"supportedActions": ["ui.activate", "video.create"],
"risk": { "level": "confirm", "tags": ["external_effect"] }
}
]
}

Wenn der Planner dann tatsächlich eine Aktion auswählt, kann die Host-Schicht das Ziel für die Ausführung mit zusätzlichen Informationen anreichern:

interface ExecutionTarget {
stableId?: string;
documentId: string;
scopeId?: string;
role: string;
name?: string;
bbox?: { x: number; y: number; width: number; height: number };
runtimeHints?: { css?: string; xpath?: string };
actionability?: {
attached?: boolean;
inViewport?: boolean;
obscured?: boolean;
stable?: boolean;
};
}

Wenn der Snapshot sensible Werte enthält, sollte die Host-Schicht zuerst redigieren und danach den Planner-Kontext bauen. Sonst landet das Passwortfeld mit Maskierung vielleicht nicht im Prompt, aber der Rohwert steckt noch in textValue oder semanticValue einer früheren Zwischenrepräsentation. Menschen nennen so etwas „kleines Versehen“. Auditoren eher nicht.


2.1 Ja, das Capability-Dokument lässt sich direkt in Tools übersetzen

Abschnitt betitelt „2.1 Ja, das Capability-Dokument lässt sich direkt in Tools übersetzen“

Das Capability-Dokument ist fast schon die Vorlage für Tool-Definitionen. Ein ActionDescriptor enthält bereits:

  • id
  • kind
  • targetKinds
  • requiredAffordances
  • executionModes
  • args
  • idempotency
  • risk
  • success

Pragmatisch heißt das:

  • Domain-Actions werden fast immer als eigene Tools exponiert.
  • Primitive Actions können entweder direkt exponiert oder über ein generisches run_uiap_action-Tool geroutet werden.
  • Die aktuell im Prompt sichtbaren Tools sind idealerweise die Schnittmenge aus globalem Capability-Dokument und lokalem supportedActions auf aktuell relevanten Elementen/Scopes.

Variante A: ein Tool pro Action

Gut für kleinere Apps oder wenn ihr dem Modell explizit viele Domain-Actions geben wollt.

Variante B: generisches Runtime-Tool + Action-Shortlist

Gut für große Kataloge. Das Modell bekommt dann im Kontext eine kleine Liste erlaubter Actions und ruft ein einziges Tool auf, das action.request baut.

In der Praxis ist B oft stabiler, weil das Modell nicht mit 200 Funktionsnamen jonglieren muss wie ein übermüdeter Zirkusdirektor.

interface LLMTool {
name: string;
description: string;
inputSchema: Record<string, unknown>;
meta: {
uiapActionId: string;
risk: ActionDescriptor["risk"];
idempotency?: ActionDescriptor["idempotency"];
executionModes: ActionDescriptor["executionModes"];
success?: ActionDescriptor["success"];
};
}
function compileActionTool(action: ActionDescriptor): LLMTool {
const needsTarget = action.targetKinds.some((kind) => kind !== "none");
const properties: Record<string, unknown> = {};
const required: string[] = [];
if (needsTarget) {
properties.target = {
type: "object",
additionalProperties: false,
properties: {
stableId: { type: "string" },
scopeId: { type: "string" },
role: { type: "string" },
name: { type: "string" }
}
};
required.push("target");
}
for (const arg of action.args ?? []) {
properties[arg.name] = argToJsonSchema(arg);
if (arg.required) required.push(arg.name);
}
return {
name: action.id.replaceAll(".", "_").replaceAll("-", "_"),
description: action.description ?? action.title ?? action.id,
inputSchema: {
type: "object",
additionalProperties: false,
properties,
required
},
meta: {
uiapActionId: action.id,
risk: action.risk,
idempotency: action.idempotency,
executionModes: action.executionModes,
success: action.success
}
};
}
function argToJsonSchema(arg: ActionArgDescriptor): Record<string, unknown> {
if (arg.type === "enum") {
return { type: "string", enum: arg.enum ?? [] };
}
if (arg.type === "array") return { type: "array" };
if (arg.type === "object") return { type: "object" };
if (arg.type === "number") return { type: "number" };
if (arg.type === "boolean") return { type: "boolean" };
return { type: "string" };
}

2.4 Erwartete Ergebnisse nicht mit Tool-Args verwechseln

Abschnitt betitelt „2.4 Erwartete Ergebnisse nicht mit Tool-Args verwechseln“

success-Signale gehören in der Regel nicht in die Tool-Eingabe, sondern in die Tool-Metadaten oder in die Controller-Logik. Das Tool sollte semantisch sagen: „Ich will video.create mit diesen Args“, nicht: „Und hier sind noch acht interne Verifikationsdetails, weil das Modell sonst nervös wird.“

Eine brauchbare Tool-Rückgabe ist eher so geformt:

type ToolResult =
| { status: "accepted"; actionHandle: string }
| { status: "waiting_confirmation"; actionHandle: string; preview?: unknown }
| { status: "waiting_user"; note: string }
| { status: "completed"; result: ActionResultPayload }
| { status: "blocked"; reason: string };

Nicht jede Capability muss ständig im LLM sichtbar sein. Eine gute Host-Schicht zeigt pro Turn nur:

  • Actions, die zur aktuellen Route oder den aktiven Scopes passen
  • Actions, die von sichtbaren supportedActions gestützt werden
  • Domain-Actions aus den Top-Workflows
  • ein paar sichere Primitive wie ui.read, ui.focus, ui.enterText, ui.activate, nav.navigate

Der Rest bleibt intern vorhanden, aber außerhalb des aktuellen Planner-Budgets.


3.1 Workflows sind weder heilige Schrift noch bloße Deko

Abschnitt betitelt „3.1 Workflows sind weder heilige Schrift noch bloße Deko“

Verfügbare Workflow-Definitionen sollte ein Agent in drei Modi nutzen:

  1. Als ausführbare Rezepte Wenn ein Workflow gut matcht, die nötigen Inputs bekannt sind und der gewünschte Modus (guide, assist, auto) erlaubt ist, ist uiap.workflow.start oft besser als freiformige Einzelplanung.

  2. Als Planskelett Wenn der Workflow grundsätzlich passt, aber aktuelle UI oder Inputs leicht abweichen, kann der Planner die Schritte als Vorlage verwenden und lokal anpassen.

  3. Als negative Leitplanke handoff-, collect-, ensure- und confirm-Muster zeigen dem Agenten, wo er gerade nicht kreativ werden sollte.

Ein Workflow-Katalog kann groß sein. Im Planner-Kontext genügen meist die Top 1 bis 3 Kandidaten. Dafür ist der Match-Prozess wichtig:

  • Intent- oder Zieltext matchen
  • routeId und aktuelle Scopes prüfen
  • requiredActions gegen Capabilities prüfen
  • Rollen, Grants und Policy-Lage prüfen
  • mode filtern
interface WorkflowRecipe {
workflowId: string;
score: number;
reason: string;
missingInputs: string[];
steps: Array<{
id: string;
type: string;
actionId?: string;
parameterNames?: string[];
}>;
}
async function buildWorkflowRecipes(goal: string, routeId?: string): Promise<WorkflowRecipe[]> {
const matches = await workflowClient.match({
intent: goal,
routeId,
mode: "assist",
maxResults: 3
});
return matches.candidates.map((candidate) => ({
workflowId: candidate.workflowId,
score: candidate.score,
reason: candidate.reason ?? "workflow matched",
missingInputs: candidate.missingInputs ?? [],
steps: projectWorkflow(candidate.workflowId)
}));
}

Statt der vollen Definition reicht oft dieses Format:

[
{
"workflowId": "video.create_first_video",
"score": 0.92,
"missingInputs": ["title"],
"steps": [
{ "id": "collect_title", "type": "collect", "parameterNames": ["title"] },
{ "id": "go_to_form", "type": "action", "actionId": "nav.navigate" },
{ "id": "fill_title", "type": "action", "actionId": "ui.enterText" },
{ "id": "create_video", "type": "action", "actionId": "video.create" },
{ "id": "done", "type": "complete" }
]
}
]

Das ist für Planung meist viel nützlicher als die komplette Workflow-Definition mit jedem Lokalisierungs- und Review-Detail.

3.4 Authoring- und Discovery-Herkunft ernst nehmen

Abschnitt betitelt „3.4 Authoring- und Discovery-Herkunft ernst nehmen“

Wenn Workflows aus einem authoritativen Bundle kommen, können sie als echte Rezepte dienen. Wenn sie nur aus Discovery-Kandidaten stammen, sollten sie eher als Hinweise oder Skelett gelten, nicht als autonome Wahrheit. Discovery liefert Kandidaten mit Confidence und Review-Bedarf; Authoring macht daraus erst veröffentlichte, effektive Laufzeitartefakte.

3.5 Gute Rollenverteilung zwischen LLM und Workflow Engine

Abschnitt betitelt „3.5 Gute Rollenverteilung zwischen LLM und Workflow Engine“

Eine robuste Arbeitsteilung sieht so aus:

  • Workflow Engine: Applicability, Step-Reihenfolge, Checkpoints, Policy-Integration, Resume
  • LLM: Intent-Matching unterstützen, fehlende Inputs beschaffen, Vorschläge formulieren, bei Abweichungen zwischen UI und Rezept lokal adaptieren, Nutzer verständlich durch waiting_confirmation oder waiting_user führen

Wenn der Workflow sauber passt, sollte die Engine führen. Freiform-Planung ist nicht heroisch, wenn schon ein gutes Rezept existiert.


Die Policy-Spezifikation ist bewusst entscheidungsorientiert, lokal durchsetzbar und vor nicht trivialen Actions auszuwerten. Praktisch heißt das:

  • Das Policy-Dokument selbst ist nicht die Hauptschnittstelle zum LLM.
  • Die Policy-Entscheidung ist die operative Wahrheit.
  • Das LLM bekommt höchstens eine kompakte Zusammenfassung globaler Regeln plus die konkrete Entscheidung pro Action.

Der vernünftige Aufbau ist dreischichtig:

  1. System-/Planner-Hinweise Kurze, stabile Regeln wie: keine Credential-Eingabe, confirm heißt nicht autonom weiterlaufen, handoff ist kein Fehler.

  2. Policy Summary im Kontext Welche Grants hat der aktuelle Principal? Welche Domänen sind generell handoff- oder deny-lastig?

  3. Preflight vor jeder Action Die Host-Schicht ruft policy.evaluate auf und behandelt das Ergebnis als Hard Constraint.

interface PolicyHints {
principal: {
id: string;
roles?: string[];
grants?: string[];
};
hardStops: string[];
confirmRules: string[];
handoffRules: string[];
redaction: Array<{
applyTo: string[];
replacement: string;
}>;
}

Beispiel:

{
"principal": {
"id": "workspace-admin",
"roles": ["admin"],
"grants": ["observe", "guide", "draft", "act"]
},
"hardStops": [
"credential/secret data is never exposed directly to the model",
"blocked actions are not executed autonomously"
],
"confirmRules": [
"confirm-risk actions require explicit confirmation before execution"
],
"handoffRules": [
"user activation, credential entry and payment approval cause handoff"
],
"redaction": [
{ "applyTo": ["snapshot", "audit", "returnValue"], "replacement": "[REDACTED]" }
]
}
async function preflightAction(input: {
actionId: string;
target?: { stableId?: string; role?: string; scopeId?: string; name?: string };
risk?: RiskDescriptor;
dataClasses?: string[];
sideEffectClass?: string;
args?: Record<string, unknown>;
}): Promise<PolicyDecision> {
return policyClient.evaluate({
context: {
principal: currentPrincipal,
actionId: input.actionId,
target: input.target,
risk: input.risk,
dataClasses: input.dataClasses,
sideEffectClass: input.sideEffectClass,
args: input.args
}
});
}

Das Ergebnis wird dann strikt behandelt:

function handlePolicyDecision(decision: PolicyDecision):
| { kind: "proceed" }
| { kind: "confirm"; obligations?: unknown[] }
| { kind: "handoff"; obligations?: unknown[] }
| { kind: "deny"; reasonCodes: string[] } {
switch (decision.decision) {
case "allow":
return { kind: "proceed" };
case "confirm":
return { kind: "confirm", obligations: decision.obligations };
case "handoff":
return { kind: "handoff", obligations: decision.obligations };
case "deny":
default:
return { kind: "deny", reasonCodes: decision.reasonCodes };
}
}

4.4 Redaction und Action-Zulässigkeit getrennt halten

Abschnitt betitelt „4.4 Redaction und Action-Zulässigkeit getrennt halten“

Policy modelliert Redaction getrennt von Action-Erlaubnis. Das ist wichtig für die Agent-Integration:

  • Ein Feld kann redigiert werden, ohne dass der ganze Screen für Planung verschwindet.
  • Ein Snapshot für das Modell kann Platzhalter enthalten, während die Host-Schicht intern weiter mit strukturellem Wissen arbeitet.
  • Das LLM sollte rote Zonen als sichtbar aber maskiert sehen, nicht als „unsichtbar“, wenn die Umgebung sonst unverständlich würde.

4.5 confirm und handoff als strukturierte Zustände behandeln

Abschnitt betitelt „4.5 confirm und handoff als strukturierte Zustände behandeln“

Diese Fälle sollten nicht als freie Chat-Anweisung enden wie „Ich brauche kurz Hilfe“. Besser ist ein strukturierter Zustand im Controller:

  • confirm -> explizite Bestätigungsanfrage, danach action.confirmation.grant oder deny
  • handoff -> klarer Wechsel in Nutzerzuständigkeit
  • deny -> alternative Planung

Das Modell darf dabei erklären, aber nicht die Durchsetzung simulieren.


Ein vernünftiger Agenten-Loop sieht so aus:

  1. Session aufbauen
  2. Capabilities / Policy / Workflows laden
  3. Snapshot oder Observe-Stream starten
  4. redigierten Planungskontext bauen
  5. nächsten Schritt planen
  6. Policy-Preflight
  7. Action oder Workflow ausführen
  8. Resultat, Deltas und Signale beobachten
  9. Kontext aktualisieren und neu planen

Mit UIAP-Nachrichten ist das typischerweise:

agent -> session.initialize
app -> session.initialized
agent -> capabilities.get
agent -> uiap.policy.get (oder uicp.policy.get in älteren Drafts)
agent -> uiap.workflow.get
agent -> web.observe.start
app -> capabilities.list
app -> uiap.policy.document
app -> uiap.workflow.document
app -> web.state.snapshot
app -> web.state.delta*
app -> web.signal*
agent -> action.request
app -> action.accepted
app -> action.progress*
app -> action.confirmation.request?
agent -> action.confirmation.grant / deny?
app -> action.result
app -> web.state.delta*
app -> web.signal*

Der State Store ist nicht glamourös, aber unverzichtbar.

class StateStore {
private graph?: PageGraph;
private revision?: string;
private recentSignals: WebSignal[] = [];
applySnapshot(graph: PageGraph) {
this.graph = graph;
this.revision = graph.revision;
this.recentSignals = graph.signals ?? [];
}
applyDelta(delta: WebStateDeltaPayload) {
if (!this.graph || delta.baseRevision !== this.revision) {
throw new Error("Revision gap: full snapshot required");
}
this.graph = applyWebDelta(this.graph, delta.ops);
this.graph.revision = delta.revision;
this.revision = delta.revision;
this.recentSignals.push(...(delta.signals ?? []));
this.recentSignals = this.recentSignals.slice(-20);
}
current(): PageGraph {
if (!this.graph) throw new Error("No snapshot available");
return this.graph;
}
}

Wenn baseRevision nicht passt, sollte der Agent nicht raten, sondern einen neuen web.state.get-Snapshot anfordern.

class UIAPAgentController {
constructor(
private readonly state: StateStore,
private readonly policy: PolicyGateway,
private readonly tools: ToolRegistry,
private readonly workflows: WorkflowIndex,
private readonly planner: Planner,
private readonly runtime: RuntimeClient
) {}
async next(goal: string) {
const rawGraph = this.state.current();
const modelGraph = await this.policy.redactGraph(rawGraph);
const context = buildPlanningContext(modelGraph);
const plannerInput: PlannerInput = {
goal,
policyHints: await this.policy.hints(),
context,
workflows: await this.workflows.match(goal, context),
tools: this.tools.forContext(context)
};
const plan = await this.planner.next(plannerInput);
if (plan.kind === "workflow.start") {
return this.runtime.startWorkflow(plan.workflowId, plan.inputs);
}
if (plan.kind === "action") {
const decision = await this.policy.preflight(plan.toPolicyContext());
const policyResult = handlePolicyDecision(decision);
if (policyResult.kind === "deny") {
return { kind: "replan", reason: policyResult.reasonCodes.join(",") };
}
if (policyResult.kind === "handoff") {
return { kind: "waiting_user", obligations: policyResult.obligations };
}
if (policyResult.kind === "confirm") {
return { kind: "waiting_confirmation", obligations: policyResult.obligations };
}
return this.runtime.requestAction(plan);
}
return { kind: "respond", message: plan.message };
}
}

action.result ist wichtig, aber nicht die einzige Wahrheit. Gute Controller verwenden mindestens drei Signale:

  • action.result.verification
  • beobachtete web.signal-Ereignisse
  • web.state.delta / Revisionsfortschritt

Gerade bei ui.activate, ui.submit oder Domain-Actions sollte der Agent Erfolg nicht bloß daraus ableiten, dass ein Tool „ok“ gesagt hat. Ein sauberer UI-Wechsel, Toast, Dialogzustand oder Entity-Signal ist deutlich belastbarer.

Wenn ein Workflow gestartet wurde, ist der Loop ähnlich, aber auf Workflow-Nachrichten verschoben:

  • uiap.workflow.started
  • uiap.workflow.progress
  • uiap.workflow.input.request
  • uiap.workflow.input.provide
  • uiap.workflow.result

Der Controller sollte Workflow- und Action-Ereignisse trotzdem zusammen betrachten, weil Action-Steps intern wieder durch die Runtime laufen.


PageGraph kann groß werden. Trotzdem ist die Lösung fast nie, noch aggressiver zu komprimieren und Semantik wegzuwerfen, bis nur noch „da ist irgendwas mit einem Button“ übrig bleibt. Besser ist progressive Detaillierung:

  • zuerst Übersicht
  • dann aktiver Scope
  • dann gezielte Detailanforderung für den aktuellen Schritt

Stufe A: Übersichts-Kontext

Für den Planner-Start:

  • Route
  • aktive Dialoge/Drawer/Popover
  • Fokus
  • 15 bis 30 relevante Elemente
  • letzte 5 bis 10 Signale
  • 1 bis 3 Workflow-Rezepte
  • kuratierte Tool-Liste

Stufe B: Scope-Detail

Wenn ein bestimmter Bereich relevant wird:

  • nur ein oder zwei Scopes
  • alle wichtigen Controls im Scope
  • Validierungs- und Statuszustände
  • eventuell Relationen wie Label -> Feld oder Submit -> Form

Stufe C: Ausführungs-Detail

Nur wenn eine konkrete Action bevorsteht oder Ambiguität vorliegt:

  • documentId
  • Actionability-Felder
  • bbox
  • Runtime-Hints
  • ggf. weitere gleichnamige Kandidaten für Disambiguierung

Das Web-Profil bietet dafür die richtigen Hebel bereits an:

  • web.state.get.scopes
  • web.state.get.documents
  • includeHidden
  • includeNonInteractive
  • maxNodes

Eine Host-Schicht kann dadurch gezielt nachladen:

async function ensureScopeDetail(scopeId: string) {
if (stateHasEnoughScopeDetail(scopeId)) return;
const snapshot = await transport.request<WebStateSnapshotPayload>("web.state.get", {
scopes: [scopeId],
includeHidden: false,
includeNonInteractive: false,
maxNodes: 80
});
stateStore.applySnapshot(mergeScopedSnapshot(stateStore.current(), snapshot.graph));
}

Eine robuste Priorisierung bevorzugt:

  • stableId vor rein heuristischen Targets
  • sichtbare, interaktive Elemente vor passivem Text
  • offenen modalen Scope vor Hintergrundinhalt
  • invalid/required/busy/open vor neutralem Zustand
  • Elemente mit Domain-Action vor rein generischen Controls
  • annotierte oder registry-gestützte Semantik vor inferred

Wenn WebSemantics.sources oder Discovery-Evidenz nur auf Heuristiken beruhen, sollte der Planner das als niedrigere Confidence sehen. Das hilft dem Modell, bei unscharfen Oberflächen eher um Detail oder Bestätigung zu bitten, statt entschlossen Unsinn zu bauen. Eine seltene, aber hübsche Tugend.

Große Tabellen oder Listen sollten nicht voll im Prompt landen. Besser ist eine Signatur wie:

interface CollectionSummary {
scopeId: string;
name?: string;
count: number;
visibleItems: Array<{
stableId?: string;
role: string;
name?: string;
selected?: boolean;
supportedActions: string[];
}>;
omittedCount: number;
}

Der Planner sieht dann zum Beispiel:

  • „37 Rechnungen sichtbar, 5 im aktuellen Viewport, davon 1 selektiert“
  • „jeder Row-Item unterstützt ui.activate und billing.openSettings

Erst wenn eine bestimmte Zeile relevant wird, wird deren Scope oder Row-Kontext gezielt nachgeladen.

6.6 Caching und stabile Kontexte vom Live-Kontext trennen

Abschnitt betitelt „6.6 Caching und stabile Kontexte vom Live-Kontext trennen“

Nicht alles gehört in jeden Turn:

  • stabil über Session: Capabilities, globale Policy-Hints, Workflow-Metadaten
  • langsam veränderlich: Route, sichtbare Haupt-Scopes, Principal-Rolle
  • hochdynamisch: Fokus, Validation, Toasts, Busy-Zustände, Action-Resultate

Ein guter Prompt enthält nur das, was gerade gebraucht wird. Capabilities oder Workflow-Beschreibungen müssen nicht jedes Mal neu serialisiert werden, wenn nur ein Spinner angeht.

Als Startwert funktioniert oft:

  • 1 Route-Kontext
  • bis zu 4 aktive Scopes
  • 20 bis 30 Elemente
  • 8 aktuelle Signale
  • 1 bis 3 Workflow-Kandidaten
  • 8 bis 15 Tools oder 1 generisches Runtime-Tool + Action-Shortlist

Das ist klein genug für planbare Turns und groß genug, damit das Modell noch wirklich etwas von der Oberfläche versteht.


7. Praktische Leitlinien für eine robuste Integration

Abschnitt betitelt „7. Praktische Leitlinien für eine robuste Integration“

Das Modell darf Vorschläge machen wie:

  • nächste Aktion
  • bevorzugter Workflow
  • benötigte Zusatzdetails
  • natürliche Sprache an den Nutzer

Die Host-Schicht entscheidet jedoch über:

  • Policy-Zulässigkeit
  • Redaction
  • tatsächliche Action-Requests
  • Confirmation/Handoff
  • State-Authority und Revisionen

7.2 supportedActions lokal, actions[] global denken

Abschnitt betitelt „7.2 supportedActions lokal, actions[] global denken“

Das Capability-Dokument sagt, was grundsätzlich möglich ist. Der PageGraph sagt, was hier und jetzt auf dem aktuellen Screen sinnvoll adressierbar ist. Ein Agent sollte beide Ebenen kombinieren, nicht verwechseln.

7.3 Bei Ambiguität lieber Details nachladen als raten

Abschnitt betitelt „7.3 Bei Ambiguität lieber Details nachladen als raten“

Wenn zwei Buttons denselben Namen tragen oder ein Ziel nur heuristisch gefunden wurde, ist die richtige Reaktion nicht „wird schon passen“, sondern:

  • Scope verengen
  • Zielkandidaten explizit im Kontext zeigen
  • bei Bedarf bbox oder Nachbarschaftsinformation hinzunehmen
  • oder vom Nutzer/Workflow zusätzliche Präzisierung holen

7.4 User Activation und Human Handoff sind normale Zustände

Abschnitt betitelt „7.4 User Activation und Human Handoff sind normale Zustände“

Wenn Runtime oder Policy waiting_for_user, user_activation_required oder handoff melden, ist das kein Versagen des Agenten, sondern die richtige Reaktion auf Plattform- und Sicherheitsgrenzen.

Discovery-Pakete sind hervorragend, um Bindings, Actions und Workflow-Kandidaten vorzubereiten. In der Live-Agent-Integration sollten unreviewte Discovery-Kandidaten aber nicht denselben Status haben wie publizierte Authoring-/Bundle-Artefakte.


Wenn man das Ganze auf einen praktischen Satz eindampft, dann so:

Halte den vollen UIAP-Zustand lokal, zeige dem LLM nur eine redigierte und scope-bezogene Semantikansicht, kompiliere Capabilities in Tools, behandle Workflows als priorisierte Rezepte, lasse Policy außerhalb des Modells hart durchgreifen und verifiziere jeden Seiteneffekt über Resultate, Deltas und Signale.

Das ist keine magische Architektur. Es ist einfach die Variante, bei der ein Agent nach der dritten UI-Änderung nicht sofort wieder wie ein verwirrter Praktikant vor einer halb geöffneten Modal-Dialogbox steht.