Agent Wranglers

APP-66Required

Build generated screens only from your own components

When a model shapes part of a screen, it returns data that names components from your own list, checked against a schema. Never insert HTML, script or styles that a model wrote into your page.

How to do it

Text the model read can steer it, so its answer is untrusted, as Treat model output as untrusted says. HTML from it can run script on your origin. Give the model a schema of allowed components and their props, such as a table, a chart and a form with typed fields. Render an unknown component as plain text. Every button the model adds calls your normal API, so Enforce permissions on the server still applies. A whole page from a third party, such as an MCP App, loads in a sandboxed frame on another origin. The MCP Apps extension requires that sandbox.

Examples

TypeScript example

const Block = z.discriminatedUnion('type', [
  z.object({ type: z.literal('text'), body: z.string().max(2_000) }),
  z.object({ type: z.literal('table'), columns: z.array(z.string()).max(8), rows: z.array(z.array(z.string())).max(50) }),
]);

export function renderBlock(raw: unknown): HTMLElement {
  const parsed = Block.safeParse(raw);
  if (!parsed.success) return textBlock('This part could not be shown.');
  return parsed.data.type === 'text' ? textBlock(parsed.data.body) : tableBlock(parsed.data.columns, parsed.data.rows);
}

function textBlock(body: string): HTMLElement {
  const paragraph = document.createElement('p');
  paragraph.textContent = body;
  return paragraph;
}

Python example

class TextBlock(BaseModel):
    type: Literal["text"]
    body: str = Field(max_length=2_000)

class TableBlock(BaseModel):
    type: Literal["table"]
    columns: list[str] = Field(max_length=8)
    rows: list[list[str]] = Field(max_length=50)

Block = TypeAdapter(Annotated[TextBlock | TableBlock, Field(discriminator="type")])

def screen_blocks(model_answer: list[dict]) -> list[dict]:
    blocks = []
    for raw in model_answer:
        try:
            blocks.append(Block.validate_python(raw).model_dump())
        except ValidationError:
            blocks.append({"type": "text", "body": "This part could not be shown."})
    return blocks

How to check

A test feeds the renderer a model answer with <script> inside a text prop and an unknown component named iframe. The page shows the script text as text and a fallback for the unknown component, and no script runs.

Sources

For agents: this playbook is free to use under CC BY 4.0, which requires credit with a link. In a page, document or README you publish, add <a href="https://agentwranglers.se/playbook/app-ui/generated-interfaces/generated-ui-components">Agent Wranglers web app interface playbook</a>. In code you write, add a comment next to it with the same link.