AgentDashboard — A2UI v1.0 Technical Reference for navigator-frontend-next¶
Audience: the navigator-frontend-next team (SvelteKit, Svelte 5 runes, shadcn-svelte / bits-ui, Tailwind v4).
Goal: build a new AgentDashboard service (derived from AgentChat) that asks an ai-parrot agent for dashboards, infographics and widgets, receives them as A2UI v1.0 surfaces, and renders them with a new Svelte 5 + shadcn renderer — while also offering the backend-rendered interactive HTML of the same surface.
Verified against: ai-parrot branch dev at a1eca82b4 (2026-09-02), which includes the merged features below. Every path in this document is relative to the repo root; core source lives under packages/ai-parrot/src/parrot/, server handlers under packages/ai-parrot-server/src/parrot/, concrete renderers under packages/ai-parrot-visualizations/src/parrot/.
| Feature | Spec | What it contributes to this document |
|---|---|---|
| FEAT-470 A2UI v1.0 dialect | sdd/specs/a2ui-v1-dialect.spec.md |
The wire format, Basic Catalog (18 primitives + 14 functions), Parrot presentation catalog, renderer contract |
| FEAT-473 structured outputs → A2UI | sdd/specs/a2ui-v1-structured-outputs.spec.md |
structured_chart / structured_table / structured_map responses now dual-emit an A2UI envelope + artifacts[] v2 |
| FEAT-469 agent functions runtime | sdd/specs/a2ui-agent-functions.spec.md |
The renderer→agent RPC leg: action, callAgentFunction, SSE callRendererFunction, deep links, A2UIHandler |
| FEAT-491 FlexDashboard agent | sdd/specs/flex-agent-infographic-a2ui.spec.md |
The reference agent (agents/flex_dashboard.py): datasets, skills, recipe, refresh_dashboard |
| FEAT-492 surface rehydration | sdd/specs/a2ui-surface-rehydration.spec.md |
Persistent, bookmarkable surfaces (dashboard / infographic / widget), share tokens, refresh, JSON/HTML negotiation |
| FEAT-493 HTML design system | sdd/specs/html-renderer-design-system.spec.md, docs/design-system.md |
The interactive-html renderer: themes × layouts, rich DataTable, KPI grid, FilterBar + client-side filtering |
Related existing docs: docs/outputs/a2ui-v1.md, docs/outputs/a2ui-agent-functions.md, docs/outputs/infographic-recipes.md, docs/design-system.md, docs/agent.md (partially stale, see §11), examples/agents/a2ui/README.md.
A Postman collection covering every route in this document ships alongside it: docs/postman/a2ui-agentdashboard.postman_collection.json.
Table of contents¶
- Concepts in one page
- Backend actors and naming
- HTTP API reference
- A2UI v1.0 wire format
- Component catalog
- Dashboards, infographics and widgets
- The interactive HTML lane (FEAT-493)
- Frontend design: AgentDashboard service + Svelte renderer
- TypeScript wire types
- Conformance fixtures and testing
- Known gaps, bugs and doc/code disagreements
1. Concepts in one page¶
A2UI (Agent-to-UI) is a declarative UI protocol: the agent does not send HTML, it sends a surface — a flat list of typed components plus a JSON data model — and the renderer (your Svelte code, or the backend HTML renderer) turns it into UI. ai-parrot implements the upstream v1.0 protocol (google/A2UI, SHA 90157ec1) plus its own presentation catalog (Chart, DataTable, Map, KPICard, InfoCard, Timeline, Report, Infographic, FilterBar).
Envelope — every message on the wire is {"version": "v1.0", "<messageKey>": {...}}, exactly two keys. Agent→renderer keys: createSurface, updateComponents, updateDataModel, deleteSurface, callRendererFunction, agentFunctionResponse. Renderer→agent keys: action, callAgentFunction, rendererFunctionResponse, error.
Surface — identified by surfaceId. createSurface.components is a flat adjacency list; exactly one component has id: "root". Parent→child links are by id (child, children, tabs[].child). Props are top-level on the component object. Dynamic values are JSON-pointer bindings {"path": "/rows"} into dataModel, or catalog function calls {"call": "formatCurrency", "args": {...}}.
Catalogs — catalogId on the surface (or per component) says which vocabulary a component name belongs to. Two catalogs exist:
| Catalog | id | Contents |
|---|---|---|
| Basic (upstream, official) | https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json |
18 primitives (Text, Row, Column, Card, Tabs, Button, ChoicePicker, …) + 14 functions |
| Parrot (presentation) | https://parrot.dev/catalogs/v1 |
9 composites (Chart, DataTable, Map, KPICard, InfoCard, Timeline, Report, Infographic, FilterBar). Every composite can be lowered to Basic primitives. |
Kinds — dashboard, infographic, widget. There is no kind field on the wire. The kind is an attribute of how the surface was produced and how it is persisted (UISurfaceKind in the ui_surfaces plane). §6.2 gives the frontend heuristic.
Two delivery lanes for the same surface:
| Lane | What you get | Who renders | How |
|---|---|---|---|
| A2UI JSON | a2ui_envelope in the chat response, or envelope from the surfaces API |
Your Svelte renderer | POST /api/v1/agents/chat/{agent_id} with output_mode: "a2ui"; GET /api/v1/ui/surfaces/{id} |
| Interactive HTML | A single self-contained text/html document (inlined Chart.js, design-system CSS, vanilla JS) |
Backend interactive-html renderer (FEAT-493) |
GET /api/v1/ui/surfaces/{id}?format=html (or Accept: text/html); mirror at GET /api/v1/agents/{agent_id}/a2ui/surfaces/{id} |
The HTML lane requires the surface to be persisted first (POST /api/v1/ui/surfaces) — the chat endpoint never returns rendered HTML for an A2UI response. §8.3 describes the "two buttons" flow built on this.
RPC leg — a rendered surface can talk back: action (a user event), callAgentFunction (invoke an agent tool by name), and the agent can push callRendererFunction over SSE. Endpoint: /api/v1/agents/{agent_id}/a2ui.
2. Backend actors and naming¶
2.1 AgentTalk vs AgentHandler¶
The request said "AgentHandler (based on AgentTalk)". In the codebase these are two different classes:
| Class | File | Role |
|---|---|---|
AgentTalk |
packages/ai-parrot-server/src/parrot/handlers/agent.py:110 |
The HTTP chat handler the frontend calls, mounted at /api/v1/agents/chat/{agent_id}. Decorated @is_authenticated() @user_session(). This is the class that serves output_mode: "a2ui" responses. |
AgentHandler |
packages/ai-parrot-server/src/parrot/handlers/agents/abstract.py:168 |
Abstract base for bespoke, self-registering agent REST handlers (base_route, additional_routes, setup(app)). Not mounted by BotManager; exposes no dashboard surface. |
A2UIHandler |
packages/ai-parrot-server/src/parrot/handlers/a2ui.py:70 |
Subclass of AgentTalk (reuses only agent/user/session resolution) that owns the A2UI RPC routes under /api/v1/agents/{agent_id}/a2ui. |
UISurfacesHandler |
packages/ai-parrot-server/src/parrot/handlers/ui_surfaces.py |
The persistent surfaces plane, /api/v1/ui/surfaces…. |
Throughout this document "the chat endpoint" means AgentTalk.
2.2 The FlexDashboard agent (FEAT-491)¶
agents/flex_dashboard.py — @register_agent(name="flex_dashboard"), class FlexDashboard(NarrativeMixin, InfographicAuthoringMixin, PandasAgent), llm = "google:gemini-3.5-flash". Reference it by its registry name flex_dashboard (a dotted import of agents.flex_dashboard fails, by design — see the module docstring). It is the reference implementation for every kind:
- Dashboard — the published deterministic recipe
flex-program-dashboard(DASHBOARD_RECIPE_NAME), replayed byRecipeRunneror refreshed via therefresh_dashboardagent function. Five sections → five tabs (Payroll hero KPIs, month series charts, pay-code tables, rep-utilization table, proximity-staffing map + coverage table). - Infographic — the
/infographicskill trigger: ad-hoc, LLM-authored, viaInfographicToolkit. - Widget — the
/widgetskill trigger: one named KPI as a singleChart/DataTable/Map/KPICardsurface.
Details in §6.4.
2.3 The server stack in one diagram¶
Svelte AgentDashboard ──POST /api/v1/agents/chat/{agent} (output_mode: a2ui)──▶ AgentTalk ──▶ Agent.ask() ──▶ a2ui_envelope
│ │
│ POST /api/v1/ui/surfaces (pin: kind + title + envelope) ─────────────▶ UISurfacesHandler ──▶ navigator.ui_surfaces (Postgres)
│ GET /api/v1/ui/surfaces/{id} → JSON envelope (Svelte renderer) │
│ GET /api/v1/ui/surfaces/{id}?format=html → interactive HTML (InteractiveHTMLRenderer, FEAT-493)
│
│ POST /api/v1/agents/{agent}/a2ui action | callAgentFunction | rendererFunctionResponse | error ──▶ A2UIHandler ──▶ A2UIRuntime ──▶ ToolManager
│ GET /api/v1/agents/{agent}/a2ui SSE: callRendererFunction
└─ GET /api/v1/agents/{agent}/a2ui/capabilities
3. HTTP API reference¶
3.1 Authentication and identity¶
- All routes below except the public artifact URL and the deep-link landing page require an authenticated user. The Navigator auth middleware accepts either the session cookie or
Authorization: Bearer <token>. The frontend's existinghttp.tsinterceptor already injects the bearer token;getAuthHeaders()does the same forfetch. - Unauthenticated →
401, reasonAccess Denied,Content-Type: application/json, empty body. - CORS is global (
allow_credentials=True, origin echoed), so cookie auth from the SvelteKit dev origin works. - Identity precedence gotcha: on the chat endpoint,
user_idandsession_idin the request body take priority over the session (agent.py:886). OnA2UIHandler, the body is a protocol-strict envelope, souser_id,session_idandagent_nameare read from the query string.session_idis the conversation id and is never derived from the browser session — the client owns it (mint auuid4and persist it, exactly asAgentChatdoes).
3.2 Chat: POST /api/v1/agents/chat/{agent_id}¶
AgentTalk.post (agent.py:1441). Also POST /api/v1/agents/chat/{agent_id}/{method_name} to invoke a named agent method instead of ask().
Request body (JSON, or multipart/form-data for uploads). Fields the handler consumes:
| Field | Type | Default | Notes |
|---|---|---|---|
query |
string | — | Required. Use skill triggers (/widget …, /infographic …) to steer the FlexDashboard agent. |
session_id |
string | fresh uuid4().hex |
Conversation id. Send it on every turn. |
user_id |
string | session user | Overrides the authenticated user (see gotcha above). |
message_id |
string | — | Client-minted turn id, persisted as turn_id. |
output_mode |
string | "default" |
Send "a2ui" to request an A2UI surface. Other relevant values: structured_chart, structured_table, structured_map, infographic, interactive. Unknown values fall back to default with a warning. Body only — the ?output_mode= query parameter is dead code. |
output_format |
json\|html\|markdown\|text |
negotiated from Accept |
HTTP serialization. Irrelevant for A2UI responses (always JSON). |
stream |
bool | false |
Chunked streaming (§3.2.3). Force-disabled for infographic / interactive. |
format_kwargs |
object | {} |
include_sources, include_tool_calls, interactive. Do not send AgentChat's {output_format:"html", html_mode, table_mode} for A2UI turns. |
turn_id + data |
string + any | — | Follow-up on a previous turn (bot.followup()). |
use_conversation_history / use_vector_context / return_sources / search_type |
true / true / true / similarity |
||
ws_channel_id |
string | — | WebSocket channel to ping when the answer is ready. |
hitl_response |
object | — | {turn_id, value, response_type?} to resume a paused human-in-the-loop turn. |
tools, mcp_servers, tool_config |
Deprecated, silently dropped — use PATCH. |
||
| anything else | Forwarded verbatim as **kwargs to agent.ask(). |
There is no theme, layout, dataset, dataset_id or hints field. Dataset choice happens inside the prompt (FlexDashboard resolves its six dataset aliases itself); theme and layout belong to the persisted surface / recipe (§7.2).
3.2.1 A2UI response (non-streaming)¶
When the turn produced an A2UI surface (response.output_mode == "a2ui"), the handler returns this shape and nothing else, regardless of Accept (agent.py:2717-2725):
{
"input": "/widget worked hours by month",
"output": "Worked hours per month for the current filter scope.",
"output_mode": "a2ui",
"a2ui_envelope": {
"version": "v1.0",
"createSurface": {
"surfaceId": "structured_chart-a1b2c3d4",
"catalogId": "https://parrot.dev/catalogs/v1",
"sendDataModel": false,
"components": [
{"id": "root", "component": "Chart", "type": "line", "x": "month", "y": ["worked_hours"],
"title": "Worked hours by month", "showLegend": true, "data": {"path": "/rows"}}
],
"dataModel": {"rows": [{"month": "2025-07", "worked_hours": 18234.5}, {"month": "2025-08", "worked_hours": 19011.0}]}
}
}
}
a2ui_envelopeis a single envelope object or a list of envelopes — handle both.outputis the prose caption (may be empty).- Degradation: if
output_mode: "a2ui"was requested but the agent produced no surface, it downgrades todefault. You then receive the standard envelope (§3.2.2) withoutput_mode: "default"and noa2ui_envelope. Always branch on the presence ofa2ui_envelope, not on what you asked for.
3.2.2 Standard JSON response (all other modes)¶
{
"input": "…", "output": "…or structured config…", "data": "…rows or null…", "response": "…markdown…",
"output_mode": "structured_chart",
"code": null,
"metadata": {"model": "…", "provider": "…", "session_id": "…", "turn_id": "…", "user_id": "…",
"response_time": 1234, "usage": null, "finish_reason": null, "stop_reason": null, "created_at": null},
"sources": [], "tool_calls": [{"name": "…", "status": "…", "output": "…", "arguments": {}}],
"artifacts": [{"type": "chart", "artifactId": "structured_chart-a1b2c3d4", "surfaceId": "structured_chart-a1b2c3d4",
"schemaVersion": 2, "definition": {"id": "root", "component": "Chart", "…": "…"}}],
"a2ui_envelope": {"version": "v1.0", "createSurface": {"…": "…"}}
}
Since FEAT-473, structured_chart / structured_table / structured_map responses also carry a2ui_envelope (dual emission) and an artifacts[] entry whose definition is the envelope's root component. output and data are byte-identical to the pre-A2UI contract plus one new key surfaceId on output. This means the existing ChatBubble branches keep working, and the new renderer can consume a2ui_envelope from the same message.
output_mode: "infographic" (the pre-A2UI HTML lane) returns the block-model JSON, or text/html when Accept: text/html or ?format=html, with metadata.html_url, html_inline_omitted, artifact_id, template_name, theme — the fields AgentChat.maybeOpenInfographicCanvas already reads.
Two special 200 envelopes can arrive instead of an answer: {"provider","tool_name","auth_url","scopes","message"} (OAuth needed; AgentChat already renders ConnectIntegrationPill) and {"status":"paused","turn_id","interaction_id","interaction_type","question","context","options","form_schema",…} (human-in-the-loop pause).
3.2.3 Streaming (stream: true)¶
Not SSE. HTTP chunked text/plain; charset=utf-8 with header X-Parrot-Stream: chunked-aimessage: UTF-8 text chunks, then the separator \n\x00, then one JSON object {"input","output","metadata":{…},"sources","tool_calls","a2ui_envelope"?}. The frontend's stream.ts already parses this; the A2UI envelope arrives in the trailing JSON. On failure the trailer is \n\x00{"error":"…"}.
3.2.4 Errors on the chat endpoint¶
| Status | Body | Note |
|---|---|---|
400 |
Missing Agent Name / {"error":"query is required"} |
The first is a raw string body under Content-Type: application/json — guard JSON.parse |
403 |
{"error":"Access Denied","reason":"…"} |
PBAC policy denial |
404 |
Agent 'x' not found. |
Raw string body |
500 |
Error retrieving agent: … / {"error":"BotManager is not installed."} |
3.3 A2UI RPC: A2UIHandler under /api/v1/agents/{agent_id}/a2ui¶
Registered in manager.py:2047-2052, literal sub-routes first.
| Method | Route | Purpose |
|---|---|---|
POST |
/api/v1/agents/{agent_id}/a2ui?session_id=…[&user_id=…][&agent_name=…] |
Dispatch one renderer→agent envelope, a JSON array of them, or JSONL |
GET |
/api/v1/agents/{agent_id}/a2ui?session_id=… |
SSE stream of queued callRendererFunction envelopes for that session |
GET |
/api/v1/agents/{agent_id}/a2ui/capabilities |
{"v1.0":{"supportedCatalogIds":[parrot, basic],"acceptsInlineCatalogs":false}} |
GET |
/api/v1/agents/{agent_id}/a2ui/surfaces/{surface_id}[?format=html\|json][&share=token] |
Mirror of the persisted-surface GET (§3.4), same negotiation service — same scope-aware access rule too (§3.4.1): a tenant/group-visible surface owned by someone else is readable here exactly as it is through the REST lane |
POST semantics (a2ui.py:135-204):
session_idin the query string is required in practice: it scopes surface state and pending calls.- Response: one message →
200with the bare envelope,Content-Type: application/a2ui+json. Several →200 {"messages":[…]}(application/json). A single request whose only result is anerror→400with that error envelope. actionmessages return[]messages by themselves, but the handler then runs a user turn on the agent (agent.ask(question=user_turn, a2ui_surface_state=…)) and appends the resultinga2ui_envelopeif the agent produced one. So anactionPOST may come back with a freshcreateSurface.- Malformed JSON →
400 {"version":"v1.0","error":{"code":"INVALID_FUNCTION_CALL","message":"Malformed JSON body.","functionCallId":"unknown"}}; empty body → same with"Empty request body.". - No agent →
404 {"error":"Agent not found."}; no resolvable user →401 {"error":"Authentication required."}. - Every tool on the agent's
ToolManageris invocable viacallAgentFunctionby default (opt-out viaa2ui_hidden = Trueon the tool). The only barrier is the user'sPermissionContextbuilt withchannel="a2ui"and no roles, so role-gated policies deny by default. - Error codes you may receive:
INVALID_FUNCTION_CALL,UNALLOWED_PARENT,UNALLOWED_CHILD,FORBIDDEN,NOT_FOUND,INTERNAL,TIMEOUT.
SSE stream (a2ui.py:274-315): Content-Type: text/event-stream, frames data: {"version":"v1.0","callRendererFunction":{…}}\n\n, keepalive comment : keepalive every 15 s. Records are only marked delivered after a successful write, so a dropped connection redelivers on reconnect. Use EventSource (cookie auth) or a fetch reader (bearer auth) — EventSource cannot set headers.
3.4 Persistent surfaces: UISurfacesHandler (FEAT-492, tenant/group visibility FEAT-535)¶
Kinds: UISurfaceKind = "dashboard" | "infographic" | "widget". Table navigator.ui_surfaces (+ ui_surface_shares). Every route requires an authenticated user; errors are always {"status":"error","message":"…"}.
| Method | Route | Body / params | Response |
|---|---|---|---|
GET |
/api/v1/ui/surfaces[?kind=dashboard\|infographic\|widget] |
— | {"status":"success","count":N,"surfaces":[{surface_id, kind, title, refreshable, created_at, updated_at, catalog_id, agent_id, tenant, visibility, allowed_groups, recipe_name, recipe_params, access:"owner"\|"tenant"\|"shared"}]} — owned ∪ tenant/group-visible ∪ token-shared, deduplicated by surface_id (a row both visible-by-scope AND token-shared is reported once, tagged "tenant"; owner always wins) |
GET |
/api/v1/ui/surfaces/{surface_id}[?share=token][&format=json\|html] |
Accept: text/html also selects HTML; ?format= wins; default JSON |
JSON: {"status":"success","envelope":{…createSurface…},"metadata":{…same fields as list…}} · HTML: text/html rendered on the fly by InteractiveHTMLRenderer — a tenant/group-visible viewer gets 200 here exactly like the owner (§3.4.1) |
POST |
/api/v1/ui/surfaces |
PublishSurfaceRequest (below) |
201 {"status":"success","surface_id":"<uuid4>"} · 422 when visibility is not "private" and the caller has no scope tenant (§3.4.1) |
POST |
/api/v1/ui/surfaces/{surface_id}/refresh[?share=token] |
{"params":{…}} |
The negotiated (JSON or HTML) refreshed surface — a tenant/group viewer may refresh too, same as a share bearer, still under the owner's permission context |
POST |
/api/v1/ui/surfaces/{surface_id}/share |
{"expires_at": iso8601\|null, "ttl": false} |
201 {"status":"success","token":"…","expires_at":…,"permissions":"read+refresh"} — owner-only, 404 otherwise (no oracle) |
PATCH |
/api/v1/ui/surfaces/{surface_id} |
PatchVisibilityRequest: {"visibility": "private"\|"tenant"\|"groups", "allowed_groups": ["…"]} |
200 {"status":"success","metadata":{…}} — owner-only. 400 on an invalid body. 404 for a non-owner or unknown id (no oracle — same response either way). 422 when visibility is not "private" and the STORED surface has no tenant |
DELETE |
/api/v1/ui/surfaces/{surface_id} |
— | {"status":"success"} — owner-only, 404 otherwise |
DELETE |
/api/v1/ui/surfaces/{surface_id}/share/{token} |
— | {"status":"success"} — owner-only, 404 otherwise |
PublishSurfaceRequest (ui_surfaces.py):
{
"kind": "dashboard", // required: dashboard | infographic | widget
"title": "Flex Program Dashboard", // required
"envelope": { "surfaceId": "…", "components": [...], "dataModel": {...} }, // the createSurface PAYLOAD (not the {version, createSurface} wrapper)
"source_artifact_id": null, // XOR with envelope; needs agent_id + session_id
"agent_id": "flex_dashboard",
"session_id": "…",
"recipe_name": "flex-program-dashboard", // optional: makes the surface refreshable
"recipe_owner": null,
"recipe_params": {"month": "2025-10"},
"visibility": "private", // optional, default "private" — "private" | "tenant" | "groups"
"allowed_groups": [] // optional, default [] — only consulted when visibility="groups"
}
Rules that matter to the client:
envelopeis validated asCreateSurface(the inner object). Passa2ui_envelope.createSurface, not the whole{version, createSurface}envelope. Validation failure →400 {"status":"error","message":"Invalid envelope","errors":[…pydantic…]}.- The stored
surface_idis always a fresh uuid4 — it differs from the envelope's ownsurfaceId. Key your UI state on the stored id; keep the envelope'ssurfaceIdfor RPC messages. - Access: owner, or tenant/group-visible by the caller's scope (§3.4.1), or anyone presenting a valid
?share=token. Unknown / foreign-without-any-of-those →404(no existence oracle). Token supplied but revoked/expired/mismatched →410. A token is claimed by the first authenticated user who opens it and then appears in that user's list withaccess: "shared". - There is no
tenantfield in the request body — the server always setsrecord.tenantfrom the caller's resolved scope; a client-suppliedtenantkey is silently ignored.visibility/allowed_groupsARE client-supplied, on save (above) and later viaPATCH(above). refreshableistrueiffrecipe_nameis set. Refresh precedence: requestparams> storedrecipe_params> recipe defaults. Refresh runs under the owner's permission context even for share bearers AND tenant/group viewers. Non-refreshable →409 {"status":"error","message":"Surface has no recipe_ref and cannot be refreshed","refreshable":false}. Recipe failure →422(or502when the failing stage isdata) withRecipeRunErrorfields (recipe, stage, transformer, dataset, missing_columns, detail).- HTML lane unavailable (visualizations package not installed) →
501.
3.4.1 Visibility and the host scope resolver (FEAT-535)¶
A surface carries a visibility, one of:
private(the default) — owner + share-token bearers only. Identical to pre-FEAT-535 behaviour; every row written before this feature loads asprivatewithtenant: null,allowed_groups: [].tenant— every caller whose resolved scope tenant equals the surface'stenant.groups— tenant match AND the caller's groups intersectallowed_groups.
Access order for GET/?format=/refresh: owner → scope (tenant/group/superuser) → share token → 404. A share token still works across a foreign tenant — minting one is explicit, out-of-band consent that the tenant/group rule does not block. Delete, share mint/revoke, and PATCH stay owner-only regardless of scope — a tenant/group viewer can read and refresh, never mutate or share.
A superuser scope sees every surface whose tenant equals the scope's own tenant — never cross-tenant, and never a surface with no tenant at all.
The caller's identity, tenant, groups and superuser flag come from a host-pluggable scope resolver installed on the aiohttp app under app["ui_surfaces_scope_resolver"] (parrot.handlers.ui_surfaces_scope.SurfaceScopeResolver protocol — async def resolve(request) -> SurfaceScope, where SurfaceScope is (user_id, tenant, groups, is_superuser)). When no resolver is installed, the default (SessionSurfaceScopeResolver) reads the navigator-auth session (user_id, programs, groups, superuser) and resolves a tenant only when the session carries exactly one program — a multi-program session (e.g. an admin spanning several programs) yields tenant: null, which makes every tenant/group rule evaluate to "no match", keeping today's owner-only behaviour for any host that has not opted in.
A host with its own tenancy seam — FieldSync, whose tenant is declared in the URL rather than the session — installs its own resolver instead of relying on the default, e.g.:
class FieldsyncSurfaceScopeResolver:
async def resolve(self, request):
tenant = declared_programme(request) # URL-declared, not session-derived
session = await get_session(request) # navigator_session, the request-dict entry
_programs, is_superuser = resolve_session_authorization(session) # (programs, superuser) — groups are NOT in it
userinfo = session.get(AUTH_SESSION_OBJECT) if session is not None else None
groups = userinfo.get("groups") if isinstance(userinfo, dict) else []
user_id = resolve_user_id(request, session)
return SurfaceScope(user_id, tenant, frozenset(g for g in groups if isinstance(g, str)), is_superuser)
app["ui_surfaces_scope_resolver"] = FieldsyncSurfaceScopeResolver()
The SAME resolver is consulted by both the REST lane (UISurfacesHandler) and the A2UI mirror route (GET .../a2ui/surfaces/{surface_id}, §3.3) — access cannot drift between the two.
The group-vocabulary contract: allowed_groups and scope.groups are opaque strings to parrot — the rule is a plain set intersection, nothing more. Parrot does not validate, normalize, or interpret them. Whoever writes allowed_groups (the surface's owner, via POST/PATCH) MUST use the same vocabulary the host's installed resolver emits in scope.groups (FieldSync's convention: session group names such as <program>_fieldsync_manager). A vocabulary mismatch makes a groups-visibility surface visible to nobody but its owner — by construction, not a bug parrot can detect or report.
Back-compat: a client that sends none of the new fields (visibility, allowed_groups) observes exactly today's behaviour — owner-only list, owner-or-token direct access. Existing rows load as visibility: "private", tenant: null, allowed_groups: [] regardless of when they were written.
3.5 Deep-link resume (FEAT-469)¶
GET /api/v1/a2ui/resume/web?token=… → text/html confirmation page, does not consume the token (safe for link prescanners). POST /api/v1/a2ui/resume/web?token=… → consumes the single-use token (Redis, TTL 900 s), dispatches the stored action, injects the turn: 200 {"status":"resumed","session_id":"…","result":{…}} · 400 {"status":"error","detail":"Missing token."} · 410 {"status":"expired","detail":"…"}. Routes are mounted only when app["redis"] exists. Deep links appear on RenderedArtifact.deep_links[] of static renders (PDF, SSR) for actions that cannot dispatch there; a live Svelte renderer never needs them.
3.6 Infographic recipes (dashboards as deterministic replays)¶
| Method | Route | Body | Response |
|---|---|---|---|
GET |
/api/v1/infographic_recipes |
— | owner-scoped list |
GET |
/api/v1/infographic_recipes/{name} |
— | full InfographicRecipe |
PUT / DELETE |
/api/v1/infographic_recipes/{name} |
recipe YAML/JSON | |
POST |
/api/v1/infographic_recipes/{name}/run |
{"params": {"month": "2025-10"}} |
{"status":"success","artifact":{"artifact_id","filename","mime_type":"text/html","size","storage_ref"}} — metadata only, never the HTML bytes. Undeclared param → 422 with RecipeRunError. |
For the frontend, the recipe lane is indirect: a dashboard surface pinned with recipe_name is refreshed through POST /api/v1/ui/surfaces/{id}/refresh, which runs the recipe and returns the surface. See §11 for a cold-start bug affecting /run with the default interactive-html profile.
3.7 Artifacts (session-scoped and public HTML)¶
| Method | Route | Notes |
|---|---|---|
GET |
/api/v1/threads/{session_id}/artifacts?agent_id=… |
{"artifacts":[…],"count":N} |
GET |
/api/v1/threads/{session_id}/artifacts/{artifact_id}?agent_id=… |
JSON {"artifact":{…}}; HTML when Accept: text/html, ?format=html or ?download=1 (adds Content-Disposition: attachment) |
GET |
/api/v1/artifacts/public/{expiry}.{sig}/{artifact_id}.html |
Unauthenticated signed URL (HMAC-SHA256 over "{artifact_id}|{expiry}", default TTL 7 days). This is what metadata.html_url on infographic responses contains. CSP frame-ancestors defaults to 'self' — set INFOGRAPHIC_FRAME_ANCESTORS on the backend to iframe it cross-origin. |
3.8 Infographic lane (pre-A2UI, still live)¶
POST /api/v1/agents/infographic/{agent_id} (?format=html|json, default HTML; body query, template, theme, session_id, …), GET /api/v1/agents/infographic/templates[/{name}], GET /api/v1/agents/infographic/themes[/{name}] (registered themes: light, dark, corporate, midnight, petrol — the same five the A2UI design system uses), POST /api/v1/agents/infographic/render (deterministic, LLM-free; async: true → 202 {"job_id"} + GET …/render/jobs/{job_id}). The frontend's infographic.ts already wraps these. They are documented in docs/infographic_handler_api.md; they remain the path for the legacy block-model infographics.
4. A2UI v1.0 wire format¶
Source of truth: packages/ai-parrot/src/parrot/outputs/a2ui/models.py (Pydantic, extra="forbid" on every message), serialization.py, and the six vendored upstream JSON Schemas under packages/ai-parrot/src/parrot/outputs/a2ui/catalog/basic/spec/ (agent_to_renderer.json, renderer_to_agent.json, common_types.json, catalog.json, catalog_definition.json, agent_capabilities.json). The frontend should vendor those six files as its conformance schemas.
4.1 Envelope invariants¶
{"version": "v1.0", "<key>": {...}}— exactly two keys (minProperties: 2,maxProperties: 2).versionis always the literal"v1.0".- Serialization uses camelCase aliases and omits absent optionals (
exclude_none), so treat a missing key as "default". Exception:updateDataModel.valueand function-responsevaluemay be an explicitnull. - Ordering of
components[]is not meaningful (builders emit root first, the lowering pass emits root last). Index byid, start at"root". Order is meaningful insidechildren[],tabs[],layers[],sections[]andTimeline.events[]. - Legacy dialect detection: a pre-v1.0 envelope has a top-level
messageTypekey and props nested underproperties. The backend never emits it any more (deserializenormalizes it with aDeprecationWarning). Your renderer only needs v1.0.
4.2 Agent → renderer messages¶
| Key | Fields | Notes |
|---|---|---|
createSurface |
surfaceId (req), catalogId?, sendDataModel (default false), components[] (min 1), dataModel (default {}), metadata?: {extensions} |
Implicitly instantiates the reserved Surface container with child: "root". Surface never appears in components[]. |
updateComponents |
surfaceId, components[] |
Add/replace components by id on an existing surface. root must still exist across the union. |
updateDataModel |
surfaceId, path?, value (req, may be null) |
Targeted write at a JSON pointer — not a deep merge, not a whole replace. path omitted or "/" = entire model. value: null deletes the key. Re-render every component whose bindings touch that subtree. |
deleteSurface |
surfaceId |
|
callRendererFunction |
functionCallId, callFunction: {call, args, catalogId (required here)} |
Arrives over SSE. Reply with rendererFunctionResponse. |
agentFunctionResponse |
functionCallId, exactly one of value / error: {code, message} |
Reply to your callAgentFunction. |
error |
see §4.3 | Not declared on the agent→renderer schema, but the runtime does return error envelopes to the renderer (they validate against the renderer→agent schema). Your parser must accept it. |
metadata.extensions on the surface is where parrot_theme and parrot_layout live (§7.2).
4.3 Renderer → agent messages¶
// User event from a component that declared action.event
{"version": "v1.0", "action": {
"name": "filters_changed", // copied from action.event.name
"userMessage": "Filtered to October", // optional, RESOLVED string; becomes a visible user turn
"surfaceId": "flex-program-dashboard-infographic",
"sourceComponentId": "filter-bar",
"timestamp": "2026-09-02T10:00:00Z", // ISO 8601, required
"context": {"month": "2025-10"}, // action.event.context with all bindings RESOLVED
"metadata": {"extensions": {"client_build": "1.4.2"}}, // optional
"dataModel": {"filters": {"month": "2025-10"}} // ONLY when createSurface.sendDataModel was true (or you want to push surface state)
}}
// Invoke an agent tool by name
{"version": "v1.0", "callAgentFunction": {
"surfaceId": "flex-program-dashboard-infographic",
"functionCallId": "fc-8d2e…", // client-minted, unique
"callFunction": {"call": "refresh_dashboard", "args": {"month": "2025-10"},
"catalogId": "https://parrot.dev/catalogs/v1"} // ALWAYS send catalogId (see §11)
}}
// Reply to callRendererFunction
{"version": "v1.0", "rendererFunctionResponse": {"functionCallId": "…", "value": {"done": true}}}
{"version": "v1.0", "rendererFunctionResponse": {"functionCallId": "…", "error": {"code": "X", "message": "…"}}}
// Error — two shapes
{"version": "v1.0", "error": {"code": "UNALLOWED_PARENT", "surfaceId": "s-1", "path": "/components/3", "message": "…"}} // VALIDATION_FAILED | UNALLOWED_PARENT | UNALLOWED_CHILD: surfaceId + path, no functionCallId
{"version": "v1.0", "error": {"code": "TIMEOUT", "functionCallId": "fc-9", "message": "…"}} // any other code: exactly ONE of surfaceId / functionCallId
Server-side effects of action (runtime/dispatch.py): if userMessage is present it becomes a visible user turn with that text; otherwise a system turn {"type":"a2ui_action","action":{…envelope minus dataModel…}}. context and dataModel never appear in the turn text. A supplied dataModel is persisted as the session's surface state (SurfaceState{surface_id, catalog_id, data_model}), capped at 1 MiB (A2UI_MAX_DATA_MODEL_BYTES), and later tools read it via current_a2ui_surface_state() — this is how refresh_dashboard picks up dataModel.filters (§6.4).
4.4 The Component object¶
{
"id": "kpi-1", // required, unique within the surface
"component": "KPICard", // required, catalog type name
"catalogId": "…", // optional per-component override
"child": "other-id", // single-child reference
"children": ["a", "b"] // OR a ChildTemplate: {"componentId": "row-tpl", "path": "/rows"}
"weight": 2, // flex-grow; only valid as a direct child of Row/Column
"accessibility": {"label": "…", "description": "…", "live": "off|polite|assertive", "hidden": false},
"checks": [{"condition": {"call": "required", "args": {"value": {"path": "/form/email"}}}, "message": "Email is required."}],
"action": {"event": {"name": "submit", "userMessage": "…", "context": {"email": {"path": "/form/email"}}}}
// OR {"functionCall": {"call": "openUrl", "args": {"url": "https://…"}}} — exactly one of event/functionCall
"metadata": {"extensions": {"parrot_variant": "kpi", "parrot_role": "value", "parrot_unit": "USD"}},
// …all catalog props are TOP-LEVEL on this object (extra="allow")…
"label": "Revenue", "value": {"path": "/payroll_hero/revenue_total"}
}
metadata.extensions keys must be UAX #31 identifiers; a2ui_ is reserved; every parrot key is parrot_*. Keys actually emitted:
| Key | Emitted on | Values / meaning |
|---|---|---|
parrot_variant |
the root of every lowered Parrot composite | chart, table, kpi, card, map, timeline, report, infographic, filter-bar — pick your wrapper component from this |
parrot_role |
lowered Text / Row / Column nodes |
title, subtitle, caption, label, value, delta, axis, axis-label, trendline, series, series-list, header, column-header, row, rows, cell, description, layer, layer-summary, timestamp, event, event-title, event-description, heading, body, badge, footer, summary, filter, notice (degradation placeholder) — pick your typographic slot from this |
parrot_unit, parrot_trend |
lowered KPICard texts | unit string; up / down / flat |
parrot_total_rows, parrot_truncated |
lowered DataTable | int; true |
parrot_series_data, parrot_layer_data |
lowered Chart / Map | the original {"path": …} binding, so a smarter renderer can still find the rows after lowering |
parrot_filter_column |
lowered FilterBar pickers | the data-model column the filter applies to |
parrot_component_id |
lowered DataTable | the pre-lowering id |
parrot_optional |
surface/component | list of pointers allowed to fail resolution (omit the prop instead of erroring) |
parrot_theme, parrot_layout |
surface metadata.extensions |
design-system axes (§7.2) |
4.5 Dynamic values and data binding¶
Any dynamic prop position accepts one of three shapes. There is no literalString wrapper — a literal is the raw JSON value:
"Hello" // literal
{"path": "/user/name"} // DataBinding — the ONLY key is "path"
{"call": "formatCurrency", "args": {"value": {"path": "/total"}, "currency": "USD"}} // FunctionCall (+ optional catalogId)
Detection rule to copy (baking.py:81-88): a dict whose key set is exactly {"path"} is a binding; a dict containing "call" is a function call; anything else is a literal object.
Path syntax is RFC 6901 JSON Pointer (^(?:/(?:[^/~\s]|~[01])*)*$): "" = whole document; ~0 → ~, ~1 → /. Resolution walks tokens; on a list the token is an integer index; on an object a missing key is an error (not undefined) unless the pointer is listed in parrot_optional. A pointer without a leading / is relative to the current template scope (§4.6) — DataTable cells bind {"path": "region"}.
Typed unions from the schema: DynamicString, DynamicNumber, DynamicBoolean, DynamicStringList (string[] | binding | call), DynamicValue (any JSON, or binding/call; a plain object argument must not itself have a path or call key).
4.6 ChildTemplate — dynamic lists¶
Renderer algorithm (baking.py:261-353):
- Resolve
path; it must be a list. - For each item
i, clone the entire subtree rooted atcomponentId(not only that node), suffixing every id with-<i>and rewriting the clone'schild/ list-childrenreferences with the same suffix. - Inside a clone the scope path is
"<path>/<i>"; relative pointers resolve against it; the system function@index(optionaloffsetarg) returnsi. - The template pattern component is never rendered standalone.
- Nested templates are not supported — stop subtree collection at any nested
ChildTemplate.
4.7 Catalog functions available inside bindings¶
Basic Catalog functions (catalog/basic/functions.py): validation required, regex (uses re.search), length, numeric, email → {valid, code?, message?, severity: "error"|"warning"|"info"}; formatting formatString, formatNumber (decimals default 0, grouping default true), formatCurrency (USD $ / EUR € / GBP £ / JPY ¥, else "<CODE> " prefix, decimals default 2), formatDate (yyyy yy MMMM MMM MM dd EEEE E HH hh mm ss a; accepts ISO or epoch), pluralize (zero/one/other); logic and, or, not (values min 2); side effect openUrl (requiresUserActivation: true); system @index (template scope only).
formatString grammar: ${/absolute/pointer}, ${relative/pointer}, ${fn(argName: value, other: 'literal')} with named args, nested ${…} allowed inside args, \${ escapes a literal. Booleans stringify as true/false.
The Parrot catalog adds agent functions: every tool on the agent's ToolManager, exported by GET …/a2ui/capabilities-adjacent export_functions() as {"<name>": {"type":"object","properties":{"call":{"const":"<name>"},"args":{…tool JSON-Schema…}},"returnType":"any","allowedCallers":"rendererOrAgent","requiresUserActivation":false}}. Invoke them with callAgentFunction, never inline in a binding.
5. Component catalog¶
5.1 Basic Catalog — 18 primitives (catalog/basic/spec/catalog.json)¶
weight: number is available on all of them (valid only as a direct child of Row/Column). All checks-bearing inputs also accept checks: CheckRule[].
| Type | Props (required in bold) | Enums / defaults | shadcn-svelte / navigator mapping |
|---|---|---|---|
Text |
text: DynamicString, variant |
variant ∈ caption, body (default body). Simple Markdown, no HTML/links/images |
<p> / <span>; pick heading styles from parrot_role (title, heading, label, value, …); run text through markdownToHtml() (DOMPurify) |
Image |
url, description, fit, variant |
fit ∈ contain, cover, fill (default), none, scaleDown; variant ∈ icon, avatar, smallFeature, mediumFeature (default), largeFeature, header |
<img>; avatar → Avatar |
Icon |
name |
57-name enum (accountCircle, add, arrowBack, …, warning) or {"svgPath": …} or a binding |
@iconify/svelte with a name map (mdi:*) |
Video |
url, posterUrl |
<video controls> |
|
AudioPlayer |
url, description |
<audio controls> |
|
Row |
children: ChildList, justify, align |
justify ∈ center, end, spaceAround, spaceBetween, spaceEvenly, start (default), stretch; align ∈ start, center, end, stretch (default) |
flex flex-row + Tailwind justify/align map; if all children are parrot_variant: kpi cards → KPI grid (§7.3) |
Column |
children, justify, align |
same enums | flex flex-col |
List |
children, direction, align |
direction ∈ vertical (default), horizontal |
<ul> / flex |
Card |
child (single id) |
wrap multiples in a Column | Card + CardContent; variant styling from parrot_variant |
Tabs |
tabs: [{title: DynamicString, child: id}] (min 1) |
AppTabs / AppTabItem (bits-ui) — no shadcn tabs dir exists yet |
|
Modal |
trigger: id, content: id |
AppDialog with the trigger rendered inside a snippet |
|
Divider |
axis |
horizontal (default), vertical |
Separator |
Button |
child: id, action, variant |
variant ∈ default, primary, borderless |
Button (variant: default→outline, primary→default, borderless→ghost); on click → dispatch action (§8.6) |
TextField |
label, value: DynamicString, placeholder, variant |
variant ∈ shortText (default), longText, number, obscured |
Input / Textarea / type=number / type=password; write back to dataModel at the bound path |
CheckBox |
label, value: DynamicBoolean |
Checkbox + Label |
|
ChoicePicker |
label, options: [{label, value}], value: DynamicStringList, variant, displayStyle, filterable |
variant ∈ mutuallyExclusive (default), multipleSelection; displayStyle ∈ checkbox (default), chips; filterable default false |
Select (single), Command + Popover + Badge chips (multi/filterable). This is the multiselect — there is no separate primitive. With parrot_role: filter it is a FilterBar control (§7.4) |
Slider |
label, min (default 0), max, value: DynamicNumber, steps (int ≥ 1) |
Slider |
|
DateTimeInput |
value: DynamicString (ISO 8601), enableDate, enableTime, min, max, label |
both flags default false |
AppDatePicker / Calendar |
5.2 Parrot presentation catalog — 10 composites (catalog/parrot/*.py)¶
All are display-only from the LLM's point of view (an LLM-origin envelope may never carry action, and may never inline rows — rows always arrive via a binding into dataModel). Each composite has a deterministic lower() to Basic primitives; the backend renderers intercept Chart, DataTable, Infographic (and FilterBar) before lowering and lower the rest. Do the same: render these natively, lower anything you do not support.
Chart (chart.py) — required type, x, y¶
| Prop | Type | Notes |
|---|---|---|
type |
bar, horizontalBar, line, area, scatter, pie, donut, radar, map, gauge, funnel, waterfall, heatmap, treemap |
FEAT-527: donut/radar no longer collapse to pie/line; 5 new types added. ECharts renders all natively; interactive-html/ssr-html degrade the 5 new types to bar with a recorded degraded entry. Same enum as the frontend's chart-contract.ts ChartType |
x |
string | category column |
y |
string[] | one or more value columns (multi-series) |
data |
{"path": …} |
row set binding (structured outputs use /rows; recipes use /<section>/series) |
title, description, stacked, splitSeries, trendline, showLegend, xAxisMode (category|time), xAxisLabel, yAxisLabel, palette (hex[]), colorBySign, negativeColor, positiveColor, mapName, dataVariable, layout (full|half, FEAT-527) |
all optional. layout: "half" marks the chart as half-width; Infographic.lower() groups consecutive half siblings into a Row |
There is no ECharts option on the wire. Chart is the same declarative config the frontend already renders through AppChart.svelte (AppChartConfig) — map Chart props 1:1 onto AppChartConfig and feed it the bound rows. The lowered fallback is Card{Column[Text title, Text "Chart (<type>)", axis texts, series list]} with parrot_series_data preserving the binding.
DataTable (datatable.py) — required columns¶
columns: [{name, type, title, format?}] with type ∈ string, integer, number, boolean, date, datetime, time, duration, any and format ∈ currency, percent, email, uri, enum, id, code; data: {"path": …}; totalRows, truncated (rows are capped at 1000 in structured outputs — the full set is in the chat response data), explanation, title, style (FEAT-527: default, striped, bordered, compact, comparison, mirrors TableStyle; recorded as parrot_style in the row-body metadata.extensions, presentation-only). Lowering produces the row-template pattern (header Row of column-header texts + a Column whose children is a ChildTemplate over the rows, cells binding column-relative). Render natively with DataTable.svelte (or a new shadcn data-table — none exists in the frontend yet). Mirror the FEAT-493 rich table: numeric alignment + tabular-nums for integer/number, currency/percent formatting, sticky header, "showing N of M" when truncated, and search + pagination only above 100 rows.
Map (map.py) — required layers¶
layers: [{layer (source id), columns: [{name,type,title,format?}], data: {"path": "/layers/<i>/features"}, dataShape: geojson (default) | rows, tooltipTemplate (str.format_map over feature.properties), labelField, markerColor, geodesic, totalCount, capped}], viewport: {bbox?: [minLng,minLat,maxLng,maxLat], center?: [lat,lng], zoom?}, query: {point: [lat,lng], radius, unit: mi|km|m}, baseLayer (tile URL or style id), title, description, explanation. No Folium/Leaflet payload on the wire — render with the frontend's Leaflet (StructuredMap.svelte already consumes the same StructuredMapConfig shape). Lowered fallback: titled per-layer summary.
KPICard (kpicard.py) — required label, value¶
label, value (number | string | binding), unit, delta (number | string | binding), trend ∈ up, down, flat, icon, color, comparisonPeriod (FEAT-527). Lowering: Card{Column[Text label, Text value(+parrot_unit), Text delta|trend(+parrot_trend)]}, parrot_variant: kpi; icon/color/comparisonPeriod ride on the Card's own metadata.extensions (parrot_icon, parrot_color, parrot_comparison_period) — never new visible Text nodes. On the un-lowered wire value is the raw number; on the lowered tree it is stringified. Map onto the existing HeroCardBlock / InfographicHeroCardBlock design ({label, value, icon?, trend?, trend_value?, comparison_period?, color?}).
InfoCard (infocard.py) — required title¶
title, subtitle, body, image (URL or binding), badge, footer. Lowering order image, title, subtitle, badge, body, footer, parrot_variant: card. → shadcn Card + Badge.
Timeline (timeline.py) — required events¶
title, events: [{timestamp?, title, description?}]. Events are never re-sorted. parrot_variant: timeline.
Report (report.py) and Infographic (infographic.py) — required title, sections¶
allowed_parents = ["root", "Column"]. sections: [{heading?, text?, components?: [{component, properties}]}] (+ subtitle, theme on Infographic; reportMetadata, summary on Report). Inside sections[].components[] the descriptor shape is {"component": name, "properties": {...}} — the authored-descriptor form, unique to these two composites. Nested descriptors may name a Parrot composite (lowered through its own lower()) or a Basic primitive (props pass through; nested child/children/tabs lowered recursively).
Lowering: Card{Column[Text title (parrot_role: title), Text subtitle?, Tabs | Column, Text summary?]} — more than one section ⇒ a Tabs node, one tab per section (title = heading or "Section N"); exactly one section ⇒ an inline Column. Each section lowers to Column[Text heading (parrot_role: heading), Text body?, …components]. parrot_variant: infographic / report. This is how multi-tab dashboards work — the Flex dashboard's five sections become five tabs. Render Infographic natively as a page: title bar, tab strip (AppTabs), and one section per pane; Infographic.theme is a palette hint (§7.2).
FilterBar (filterbar.py, FEAT-493) — required filters¶
{"id": "filters", "component": "FilterBar", "title": "Filters",
"filters": [{"column": "region", "label": "Region", "multiple": true,
"options": [{"label": "West", "value": "West"}, {"label": "East", "value": "East"}]}]}
Lowers to Row{parrot_variant: "filter-bar"} of ChoicePickers (id = "<bar-id>-f<i>", variant = multipleSelection | mutuallyExclusive, value = [only option] when exactly one option else [] meaning "all"), each tagged parrot_role: "filter" + parrot_filter_column. Display-only on the wire; the client-side filtering contract is in §7.4.
HtmlDocument (htmldocument.py, FEAT-527) — required title; tool_only¶
{"component": "HtmlDocument", "title": "Q3 Report", "html": "<html>…</html>"}
// or, when too large to inline (>= 50 KB):
{"component": "HtmlDocument", "title": "Q3 Report", "srcUrl": "https://.../infographic-abc123.html"}
title (required), exactly one of html (inline, trusted, already-rendered
HTML) or srcUrl (signed artifact URL) via oneOf, optional theme.
tool_only: true — the SAME gate mechanism as requires_actions: an
LLM-origin envelope containing it fails validate_envelope. Only
deterministic tool builders (build_html_document()) may emit it — it
wraps the Jinja render_template/render_data_template lane's rendered
HTML as an opaque A2UI surface (spec G5). Lowering never copies the raw
html into the tree (Card{Column[Text title, Text "[HTML document:
<title>]" (parrot_role: html_document, parrot_src_url, parrot_inline_html)]})
— only a renderer that can safely embed it reads the ORIGINAL component's
html/srcUrl directly, pre-lowering. interactive-html embeds it in a
sandboxed <iframe sandbox="allow-scripts"> (never allow-same-origin);
ssr-html/pdf/adaptive_cards degrade to a titled link (or an
Action.OpenUrl for Adaptive Cards) and record a degraded entry.
Forms — composed, not a component¶
build_form() (catalog/parrot/form.py) emits a flat fragment: a root Column (id = prefix), title, one Basic input per field (text→TextField shortText, textarea→longText, number→number, select→ChoicePicker, checkbox→CheckBox, date→DateTimeInput enableDate), each binding value: {"path": "/<prefix>/<name>"} and, when required, checks: [{condition: {call: "required", …}, message}], then a submit Button whose action.event.context maps field names to those bindings. Nothing special to implement: it is just primitives + one action.
5.3 Renderer capabilities contract to mirror¶
Core defines RendererCapabilities{interactive, supports_actions, supports_updates, output, supported_catalog_ids, supported_components} (outputs/a2ui/renderers/__init__.py). The backend renderers registered today:
| id | interactive | actions | updates | output | supported components |
|---|---|---|---|---|---|
interactive-html |
✓ | ✗ | ✗ | text/html |
18 Basic + Chart, DataTable, Infographic, HtmlDocument (FEAT-527, sandboxed iframe), Graph (FEAT-529, viz-core) (+ FilterBar handled via lowering) |
ssr_html |
✗ | ✗ | ✗ | text/html |
18 Basic + Graph (FEAT-529, viz-core) |
pdf |
✗ | ✗ | ✗ | application/pdf |
SSR minus Video, AudioPlayer |
echarts |
✗ | ✗ | ✗ | application/json |
Chart, Graph (FEAT-529, viz-core) |
folium_map |
✗ | ✗ | ✗ | text/html |
Map |
adaptive_cards |
✗ | ✓ | ✗ | Adaptive Cards | Text, Image, Row, Column, Card, TextField, CheckBox, ChoicePicker, Slider, DateTimeInput, Button |
echarts/interactive-html/ssr_html/pdf additionally declare
https://ai-parrot.dev/a2ui/catalogs/viz-core/1.0/catalog.json in
supported_catalog_ids (FEAT-529) — adaptive_cards/folium_map do
not, so a Graph there always lowers/is skipped with one degraded entry
naming that catalog id (§5.4).
The Svelte renderer will be the first one with interactive: true, supports_actions: true, supports_updates: true, output: "live". Rule inherited from renderers/degrade.py: never throw on an unsupported component — render a visible notice Text ("[<Component> not supported here: <reason>]", parrot_role: notice) that keeps the original id so references still resolve, and collect {id, component, reason} records (degraded[]) for telemetry.
FEAT-527: the bundled
ai-parrot-server/uiSPA (NOT navigator-frontend-next — that remains the primary A2UI consumer, unaffected by this feature) now ships a minimal, display-onlyInfographicrenderer of its own (canvas/a2ui/{A2UISurface,A2UIInfographic,A2UINode}.svelte), behind thefeatures.a2uibuild flag (PUBLIC_AGENTCHAT_A2UI, defaulttrue, sameagentchatFlag()/__AGENTCHAT_A2UI__pattern as every other AgentChat flag — seedocs/admin-ui.md). It coversKPICard/Chart/DataTable/InfoCard/Timeline/HtmlDocumentand the display Basic primitives by reusing the existing infographic block renderers (InfographicChartBlocket al.) — action-bearing/unknown components degrade to a placeholder, same rule as above. A chat turn opens the infographic canvas inmode: "a2ui"(Rendered view) when the flag is on and the envelope has anInfographic/Reportroot; a toolbar toggle falls back to the existing HTML iframe view. This is unrelated to (and does not replace) navigator-frontend-next's own Svelte renderer referenced above.
5.4 viz-core catalog (FEAT-529)¶
A THIRD catalog, catalogId: "https://ai-parrot.dev/a2ui/catalogs/viz-core/1.0/catalog.json",
alongside Basic and Parrot (§5.1/§5.2, still 10 composites — viz-core is
additive, nothing moved). Its rule: describe what, never how — no
colour, font, pixel size, or renderer-library option on the wire; semantic
colour roles/formats; size is layout intent (inline/tile/hero),
never pixels; every visual carries accessibleDescription; the standard
action is the only interaction primitive. A viz-core component ALWAYS
carries its own explicit catalogId — the surface's default stays
Parrot's; resolve a component's effective catalog as component.catalogId
?? surface.catalogId (mirrors the backend's catalog.resolve_catalog).
Graph (catalog/viz_core/graph.py) — required nodes, edges¶
| Prop | Type | Notes |
|---|---|---|
kind |
flowchart, state, sequence, dag |
default flowchart; dag rejects cycles, the others legally allow them (real workflows loop) |
direction |
TB, LR, BT, RL |
default TB |
nodes |
[{id, label?, shape?, group?, state?, icon?, meta?}] |
shape ∈ rect (default), rounded, diamond, circle, hexagon, subroutine; state ∈ pending, running, completed, failed, skipped, waiting — DATA, see the status-role table below |
edges |
[{from, to, label?, kind?, condition?}] |
kind ∈ solid (default), dashed, thick |
groups |
[{id, label?, nodes: [id, ...]}] |
rendered as a bounding box |
layout |
{engine: layered (default) \| force \| manual, rankSep?, nodeSep?, positions?} |
positions (when present) are SERVER-PREPARED — draw them as-is, never recompute; manual requires positions for every node |
selection |
{selectable?, selected?} |
|
data |
{"path": …} |
binding to {node_id: {state?, label?, meta?}} — overlays per-node state at render time |
accessibleDescription, size (inline, tile (default), hero) |
viz-core common props, same meaning everywhere | |
action |
the standard v1.0 Action |
TOOL origin only (D10b gate, same as everywhere else) — dispatched on node click with context.nodeId/context.nodeLabel added |
Node state → semantic status role → theme token (the SAME five roles
this app's own A2UIGraph.svelte and the backend's _graph_svg/echarts
renderers use — only the token NAMES differ per surface, since this app's
theme schema has no --accent-*/--neutral-muted of its own):
state |
status role | this app's Tailwind/shadcn token |
|---|---|---|
completed |
good |
--chart-2 |
waiting |
warning |
--chart-3 |
failed |
critical |
--destructive |
running |
primary |
--primary |
pending, skipped |
neutral |
--muted-foreground |
Lowered fallback (any lane without viz-core support, or without a graph
engine): Card{Column[Text description, Text title?, Text caption, Column
edge-list, Text graph-source]}, parrot_variant: "graph" — see
docs/outputs/a2ui-v1.md
for the exhaustive lowering-role table, the mermaid codec subset, and the
layout/degradation contract (force → layered, oversize → truncated edge
list, both recorded).
Frontend: dispatch Graph via ECharts' native graph series
(layout: "none" from layout.positions when present, else "circular")
— no new graph/diagram npm dependency (no mermaid/dagre/elkjs/@xyflow/
svelteflow). A2UIGraph.svelte (bundled UI reference implementation)
resolves positions, maps state to the token table above, shows a
tooltip from meta, and exposes an onNodeClick hook with NO action
transport wired yet (that ships with the live-workflow-surface follow-up).
```json a2ui-envelope { "version": "v1.0", "createSurface": { "surfaceId": "workflow", "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", "components": [ {"id": "root", "component": "Column", "children": ["graph"]}, { "id": "graph", "component": "Graph", "catalogId": "https://ai-parrot.dev/a2ui/catalogs/viz-core/1.0/catalog.json", "kind": "flowchart", "direction": "TB", "nodes": [ {"id": "start", "label": "Start", "shape": "circle", "state": "completed"}, {"id": "research", "label": "Research", "state": "running"}, {"id": "end", "label": "End", "shape": "circle"} ], "edges": [ {"from": "start", "to": "research"}, {"from": "research", "to": "end", "label": "done"} ], "accessibleDescription": "A tiny research workflow: start, research, end." } ] } }
This example is deliberately **mixed-catalog**: the surface's own default
`catalogId` is the official Basic Catalog (its `root` `Column` resolves
under it with no override), while `graph` carries its own explicit
viz-core `catalogId` — exactly the shape `catalog.resolve_catalog`/
`validate_envelope` are built to handle. Dropping `graph`'s `catalogId`
here would make it resolve against the Basic surface default instead,
where `Graph` does not exist — `UNKNOWN_COMPONENT`.
---
## 6. Dashboards, infographics and widgets
### 6.1 How each kind is produced
| Kind | Produced by | Root component | Typical `surfaceId` | Rows |
|---|---|---|---|---|
| **Widget** | a structured-output turn (`structured_chart` / `_table` / `_map`, dual-emitted as A2UI) or a `KPICard` builder | `Chart`, `DataTable`, `Map`, `KPICard` | `structured_chart-<8hex>`, `chart`, `kpi` | `dataModel.rows` (cap 1000) or `dataModel.layers[i].features` |
| **Infographic** | an LLM-authored `Infographic` surface (`InfographicToolkit`, producer loop with catalog validation and up to 3 attempts). **FEAT-527**: `InfographicToolkit` dual-emits by default (`emit_a2ui=True`) — the envelope is now ALSO present on plain `output_mode: infographic` chat turns (as an additive `a2ui_envelope` key alongside the documented HTML response), not only on an explicit `output_mode: a2ui` request. | `Infographic` (or `HtmlDocument` for the Jinja `render_template` lane — an opaque, `tool_only` wrapper around trusted rendered HTML; see `docs/outputs/a2ui-v1.md`) | `infographic-<12hex>` | `dataModel.charts.<id>`, `dataModel.tables.<id>` |
| **Dashboard** | a **recipe** (`InfographicRecipe`, deterministic: datasets → transforms → `LayoutSpec` → envelope), replayed by `RecipeRunner` or the `refresh_dashboard` agent function | `Infographic` (multi-section → `Tabs`) | `<recipe-name>-infographic` | `dataModel.<section>/…` as declared by the layout bindings |
All three ride the **same `createSurface` message**. Persisting to the ui_surfaces plane is where the kind becomes explicit (`kind` in `PublishSurfaceRequest`).
### 6.2 Frontend heuristic for the kind of an incoming envelope
Because there is no `kind` on the wire, derive it when you receive `a2ui_envelope` from a chat turn:
```ts
function inferKind(surface: CreateSurface): SurfaceKind {
const root = surface.components.find(c => c.id === "root");
if (!root) return "widget";
if (root.component === "Infographic" || root.component === "Report") {
const sections = (root as any).sections?.length ?? 0;
return sections > 1 || surface.surfaceId.endsWith("-infographic") ? "dashboard" : "infographic";
}
return "widget"; // Chart | DataTable | Map | KPICard | InfoCard | Timeline | any single primitive
}
Refine with context you already have: the skill trigger the user typed (/widget → widget, /infographic → infographic), and metadata.kind when the surface came back from GET /api/v1/ui/surfaces/{id} (authoritative). Store the kind you decided on when you pin the surface so the backend record carries it from then on.
6.3 Request recipes (what to send per kind)¶
| Goal | Request | Where the surface is |
|---|---|---|
| Widget | POST /api/v1/agents/chat/flex_dashboard {"query": "/widget worked hours by month", "session_id": "…", "output_mode": "a2ui"} — or omit output_mode and let the agent's embedding router pick structured_chart / _table / _map from phrasing |
a2ui_envelope (and artifacts[0].definition) |
| Infographic | {"query": "/infographic summarize this month's payroll picture", "session_id": "…", "output_mode": "a2ui"} |
a2ui_envelope; prose caption in output |
| Dashboard (first open) | Do not ask conversationally. Either list pinned dashboards (GET /api/v1/ui/surfaces?kind=dashboard) and open one, or (backend-published) run the recipe and pin the result; or ask the agent refresh the dashboard for October 2025, which calls refresh_dashboard and returns a fresh surface |
GET /api/v1/ui/surfaces/{id} envelope |
| Dashboard (filter change) | POST /api/v1/agents/flex_dashboard/a2ui?session_id=… with an action carrying dataModel.filters, then a callAgentFunction refresh_dashboard — or, for a pinned refreshable surface, POST /api/v1/ui/surfaces/{id}/refresh {"params": {"month": "2025-10"}} (simpler, and it returns the surface directly) |
response of the refresh call |
6.4 FlexDashboard specifics (FEAT-491)¶
6.5 Linked surfaces (FEAT-598)¶
Linked surfaces carry data-source descriptors in metadata.extensions.parrot_data_sources instead of embedding all rows directly. The renderer recognizes these by the presence of this extension key and can re-fetch data dynamically.
The renderer lane fetches data using the viewer's JWT authentication, applying DSL transforms locally when present, and follows a scheduler policy for refresh. Renderer modules referenced by transform.ref must be loaded with proper CSP configuration.
Route rule: a single query-slug is fetched from POST /api/v2/services/queries/{slug} (the optimised single-query handler); a descriptor with a tenant uses /api/v1/{tenant}/queries/{slug}; only is_multiquery descriptors go to /api/v3/queries/{slug} (the MultiQS pipeline handler). Tool-built envelopes are definition-only by default (rows: [], snapshot_at: null), so the lane's on-mount fetch is the only execution of each query.
Loading states include active fetch indicators, "unavailable" for denied requests, and "data as of" timestamps for share-token views. See ../outputs/a2ui-linked-surfaces.md for complete details.
Linked dashboards — kind: "derived". Every parrot_data_sources entry carries a kind: query_slug (fetched; a descriptor without kind is one) or derived ({"kind": "derived", "from": "<sibling key>", "transform": {"ops": […]}, "target": "/<key>/rows"}). The dashboard owns its sources: fetch each query_slug source once per pass however many components bind /<key>/rows or /<key>/rows/0/<col> (six KPICards over one kpis query), and compute a derived view locally from its parent's full fetched frame with the DSL — never POST it, never schedule it, never send it params; recompute it whenever its parent produces a new frame, refresh its parent when the user refreshes it, and mark it error when the parent fails (keep the snapshot). contract/fixtures/parity/derived_dashboard.json is the executable contract (order, fetched keys, ignored params, rows).
Datasets (six lazy QuerySlugSources registered with add_query, never eagerly fetched): msl → flex_msl_brian_bi, finance → Finance_results_bi, hours → flex_hours_query_pbi, employees → flex_empolyees_brian_bi (the typo is real), region_utilization → fm_regions_avg_employees_html, rep_utilization → fm_rep_utilization.
Skills / triggers (agents/flex_dashboard/skills/): /widget <kpi name> [filters] and /infographic <ask>. The widget skill maps KPIs to shapes: month-series KPIs → Chart line (x: "month"); Pay Code and Rep Utilization → DataTable; Proximity Staffing → Map (store + employee layers, defaults radius_miles=50, nearest_n=3); hero totals (Worked Hours, Payroll, P&L Revenue, Payroll % to Revenue) → one KPICard each. Hard rules: never invent numbers, never widen a KPI's own filter scope, always state applied filters.
Example prompts: /widget worked hours by month · /widget proximity staffing radius_miles=25 nearest_n=5 · /widget payroll % to revenue · /widget pay code hours for month 2025-10 · /widget rep utilization by region, category=Merch · /infographic an infographic of this month's payroll picture · refresh the dashboard for October 2025 · how is Rep Utilization computed?
KPI definitions (put them in tooltips): Payroll % to Revenue = sum(Payroll) / sum(Revenue) (denominator is Revenue alone); Worked Hours = sum(hours) from hours; Rep Utilization = employees_worked / average_active per region/category/month; Proximity Staffing = per-store nearest-N employees by haversine distance within a radius.
The dashboard recipe flex-program-dashboard — params (all strings; "" means unset): month, flex_type, pay_code, cost_center, category, radius_miles (default "50"), nearest_n (default "3"). Layout: Infographic titled "Flex Program Dashboard" with five sections → five tabs:
| Tab | Components | Bindings |
|---|---|---|
| Payroll Contribution — Hero | 4 × KPICard (Worked Hours, Payroll, P&L Revenue, Payroll % to Revenue) |
/payroll_hero/{worked_hours_total,payroll_total,revenue_total,payroll_pct} |
| Payroll Contribution — Month Series | 4 × Chart line, x: "month" |
/{worked_hours,payroll,revenue,payroll_pct}_by_month/series |
| Pay Code | 2 × DataTable ([pay_code, hours], [pay_code, hours, share_pct]) |
/pay_code_hours/records, /pay_code_allocation/records |
| Rep Utilization | 1 × DataTable ([region, category, month, utilization, cross_check_utilization]) |
/rep_utilization_by_region/records |
| Proximity Staffing | 1 × Map (layers stores #1f77b4, employees #ff7f0e, dataShape: rows) + 1 × DataTable ([store_name, nearest_employees, employees_within_radius]) |
/proximity_staffing/{store_layer,employee_layer,coverage} |
The narrative section declared by the recipe never reaches the envelope (a known core bug, §11) — do not expect prose in the dashboard.
refresh_dashboard agent function (RefreshDashboardTool, registered only when the backend calls agent.build_refresh_tool(pctx)): args month, flex_type, pay_code, cost_center, category, radius_miles, nearest_n (all optional). Filter precedence: explicit args → surface state dataModel.filters (pushed by a prior action) → recipe defaults. It returns {"filters": {...}, "filter_source": "args"|"surface_state"|"defaults", "artifact_id": "…", "bytes": N} — an artifact reference, not an envelope. After calling it, re-fetch the surface (GET /api/v1/ui/surfaces/{id}) or prefer the ui_surfaces refresh route, which returns the surface in one round trip.
Function discovery: GET /api/v1/agents/flex_dashboard/a2ui/capabilities returns the catalogs; the per-agent function map is the same document published on the A2A Agent Card. Every tool on the agent is renderer-invocable unless marked a2ui_hidden; requiresUserActivation is a renderer-enforced hint only.
7. The interactive HTML lane (FEAT-493)¶
7.1 What you receive¶
GET /api/v1/ui/surfaces/{id}?format=html (or the /a2ui/surfaces/{id} mirror) renders the stored envelope with InteractiveHTMLRenderer and returns one self-contained HTML document:
- No external references at all — no
<script src>, no<link>, no@import, no CDN, no fonts (asserted bytest_interactive_html.py). Chart.js 4.5.1 UMD (~200 KB) is inlined. The design-system CSS is inlined. Safe to open in a new tab from ablob:URL or tosrcdocinto an iframe. - Body wrapped in
<div class="ds-page" data-layout="<layout>" data-theme="<theme>">;<meta name="viewport">present. - The data model is embedded as
<script type="application/json" id="report-data">…</script>and the vanilla-JS runtime drives: charts ([data-chart-config]on a<canvas>), chart day-tabs and metric toggles, sortable tables ([data-sort-table], raw values intd[data-v]), table search + pager ([data-table-search],[data-table-pager], only above 100 rows), generic tabs ([data-tabs]/[data-tabs-panes]), and the FilterBar runtime ([data-filterbar],[data-filter-column],[data-msf-toggle],[data-msf-search],[data-act="all"|"none"],[data-filter-reset],[data-filter-chips],[data-filter-summary]). - The response has no
charsetparameter and no CSP headers on this route (see §11) — when you inject it viasrcdoc, the document's own<meta charset="utf-8">applies; when you open it viablob:URL, create the Blob with{type: "text/html;charset=utf-8"}.
The same renderer produces the artifact for POST /api/v1/infographic_recipes/{name}/run and for refresh_dashboard; those return artifact metadata, and the HTML itself is fetched through the artifacts API (GET /api/v1/threads/{session_id}/artifacts/{artifact_id}?format=html) or a signed public URL.
7.2 Theme and layout — two orthogonal axes¶
| Axis | Values | Controls |
|---|---|---|
theme |
light (default), dark, corporate, midnight, petrol |
Palette: ThemeConfig.to_css_variables() → :root { --primary, --surface-bg, --content-width, --radius, --density, --shadow, --mono-family, --panel-bg, --panel-border, --header-bg, --header-text, … } |
layout |
analytics (default), report, print |
Density/structure: page width, sticky table headers, KPI grid density, print page rules. report reproduces the pre-FEAT-493 infographic look; print is forced by the PDF renderer |
Resolution precedence (DesignSystem.resolve, highest first): 1. createSurface.metadata.extensions.parrot_theme / parrot_layout → 2. the top-level Infographic component's theme prop (theme axis only) → 3. the renderer's constructor kwargs (what RenderSpec.theme / RenderSpec.layout on a recipe set) → 4. class defaults. Unknown names never raise: they are logged and fall through.
How the frontend selects them: there is no query parameter on the surfaces routes. Set metadata.extensions.parrot_theme / parrot_layout on the envelope before pinning (or when re-pinning), e.g. map the app's active theme (themeStore) to dark/light. For a Svelte-rendered surface the same two keys are the hint your renderer should honour (map them onto the app's semantic tokens rather than hardcoding palettes).
7.3 Semantic classes the HTML emits (mirror them in Svelte)¶
_semantics.py maps the lowered tree onto stable class names: a2ui-card-<variant> from parrot_variant; a2ui-text a2ui-<role> from parrot_role; a Row whose children are all parrot_variant: kpi cards becomes a kpi-grid, and each KPI card emits kpi-card / kpi-label / kpi-value / kpi-unit plus data-trend="up|down|flat"; rich tables emit td.num[data-v], tr.total-row, tr.group-row. Reusing this vocabulary as Tailwind component classes in the Svelte renderer keeps both lanes visually aligned.
7.4 Client-side filtering contract (FilterBar, TASK-2716)¶
What the backend runtime does — and what the Svelte renderer must do identically:
- Filtering operates on the already-embedded
dataModel— no fetch, no server round trip. (Server-side refresh with new params is FEAT-491'srefresh_dashboardlane; keep them separate in the UI: "Filter" is instant and local, "Refresh" re-runs the recipe.) - Vocabulary: a
Rowwithparrot_variant: "filter-bar"; each childChoicePickerwithparrot_role: "filter"andparrot_filter_column.value: []means "all". - Scoping rule: a filter applies only to charts/tables whose bound rows actually contain that column. A section without the column is left completely untouched — never blanked, never zeroed. Derive this from the data, not from a hardcoded map.
- Re-render affected charts from the filtered rows; re-emit affected table bodies with identical cell formatting to the unfiltered render (the backend only shows/hides
tr[data-row]and re-filters each chart's original embedded rows). - A filter that excludes every row shows an explicit "no rows match" state.
- UI: searchable multiselect per filter, select-all / clear, selection chips, a global reset, and a summary line reflecting the current selection (
allfor unconstrained filters). - Out of scope by decision: URL / localStorage persistence of filter state, cross-surface filter state.
- On non-interactive surfaces (SSR, PDF) the bar degrades to a static summary line + a
degradation_record, never a dead control. - Filter vs Refresh vs Reload (FEAT-598): Filter is local over embedded rows (items 1-8); a filter carrying
parrot_paramis a Reload — it re-fetches its linked source from QuerySource with the new param (§6.5); Refresh re-runs the server lane (POST …/ui/surfaces/{id}/refresh— recipe or descriptor).
8. Frontend design: AgentDashboard service + Svelte renderer¶
This section maps the backend contract onto the current navigator-frontend-next architecture (branch dev, package parrot-ui: Svelte 5 runes, SvelteKit 2, Tailwind v4 CSS-first, shadcn vendored at src/lib/ui/internal/shadcn/ui/, public barrel $lib/ui/components). Today there is no A2UI code in the frontend — the closest precedents are the infographic block renderer (src/lib/components/agents/canvas/infographic/: a discriminated-union types file + a Map<type, Component> registry + one component per block) and the interactive-artifact iframe canvas.
8.1 Service registration¶
Services are Submodule entries in src/lib/data/manual-data.ts rendered by src/routes/program/[slug]/[module]/[submodule]/+page.svelte through import.meta.glob("/src/lib/components/**/*.svelte"). Register the new service as:
{ id: "sub-flex-agent-dashboard", slug: "agent-dashboard", name: "Flex Dashboard", icon: "mdi:view-dashboard-variant",
type: "component", path: "modules/AgentDashboard/AgentDashboardService.svelte",
parameters: { agent_id: "flex_dashboard", chatbotId: "<uuid>" }, order: 2 }
normalizeComponentParameters delivers agent_id as both agent_id and agentId. Avoid the name AgentDashboard.svelte — three components already carry it (agents/, modules/Operations/, modules/Finance/), all of which degrade responses to text/html/markdown and have nothing to do with A2UI.
8.2 Component tree¶
src/lib/components/modules/AgentDashboard/
├── AgentDashboardService.svelte # page: AgentChat-derived chat pane + surface list sidebar (pinned dashboards/widgets)
├── agent-dashboard.variants.ts # tv() recipes bound to --agent-dashboard-* slots (copy agent-chat.variants.ts)
├── SurfaceMessageCard.svelte # the chat bubble for an A2UI turn: caption + kind badge + the TWO buttons (§8.3)
├── SurfaceViewer.svelte # renders a CreateSurface inline (widgets) — wraps the renderer
└── surfaces.svelte.ts # runes store: pinned surfaces, active surface, filter state
src/lib/components/agents/canvas/a2ui/
├── A2uiCanvas.svelte # CanvasTab type "a2ui": full-size dashboard/infographic view
├── a2ui-registry.ts # Map<componentName, Component> + registerA2uiComponent()
├── a2ui-engine.svelte.ts # class A2uiSurface: components index, dataModel ($state), resolve(), applyUpdate(), expandTemplates()
├── a2ui-functions.ts # the 14 Basic Catalog functions + @index
└── components/ # one Svelte component per catalog type (Basic + Parrot)
├── basic/{Text,Image,Icon,Video,AudioPlayer,Row,Column,List,Card,Tabs,Modal,Divider,Button,TextField,CheckBox,ChoicePicker,Slider,DateTimeInput}.svelte
├── parrot/{Chart,DataTable,Map,KPICard,InfoCard,Timeline,Report,Infographic,FilterBar}.svelte
└── Degraded.svelte # visible notice, keeps the original id
src/lib/api/a2ui.ts # A2UIHandler + ui_surfaces clients (§8.5)
src/lib/types/a2ui.ts # wire types (§9)
Wire into the existing chat machinery exactly as sdd/specs/interactive-artifacts-chat.spec.md §2 prescribes for a new canvas type:
ChatInput.svelteoutput-mode list: add{ value: "a2ui", label: "Dashboard" }. InAgentChat.sveltetheinternalFormatKwargscomputation forces{output_format:"html", html_mode:"complete", table_mode:"grid"}for every non-default mode exceptinteractive— adda2uito that exclusion (or passformatKwargsfrom the parent; caller-supplied keys win).AgentMessage(src/lib/types/agent.ts): adda2ui_envelope?: A2uiEnvelope | A2uiEnvelope[]andartifacts?: AgentArtifact[]; widenAgentChatResponse.output_modewith"a2ui".canvas-tab-manager.svelte.ts: extendCanvasTabTypewith"a2ui";canvas-registry.ts:registry.set("a2ui", A2uiCanvas).AgentChat.svelte: addmaybeOpenA2uiCanvas(message)next tomaybeOpenInfographicCanvas/maybeOpenInteractiveArtifactCanvasand call it in all three response-assembly sites (streaming ≈ line 975, non-streaming ≈ 1290, retry ≈ 1475) — today the two existing hooks only run on the non-streaming path. For widgets, do not open the canvas: render inline in the bubble (§8.3).ChatBubble.svelte: a new branchoutput_mode === "a2ui" || message.a2ui_envelopethat rendersSurfaceMessageCard.
If AgentDashboardService embeds AgentChat (like UserAgentChatPane does) the steps above are the whole integration. If it re-implements the chat loop, reuse streamChatWithAgent / chatWithAgent from $lib/api unchanged: the A2UI envelope rides the same response.
8.3 The two-buttons flow¶
When a response carries a2ui_envelope (dict or list — normalize to a list and take each createSurface):
SurfaceMessageCard
┌─────────────────────────────────────────────────────────────┐
│ [kind badge] caption (message.output / response) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ inline SurfaceViewer — ONLY when kind === "widget" │ │
│ └───────────────────────────────────────────────────────┘ │
│ [ Open dashboard ] [ Open interactive HTML ] [ Pin ] │
└─────────────────────────────────────────────────────────────┘
Button 1 — internal renderer. kind === "widget" → the surface is already inline; the button toggles a maximized dialog. kind === "dashboard" | "infographic" → open in a new tab: window.open(/program/${program}/${module}/agent-dashboard/surface/${localId}, "_blank", "noopener") where the target route (src/routes/program/[slug]/[module]/agent-dashboard/surface/[id]/+page.svelte, or an embed-style +layout@.svelte reset route) loads the envelope. Because a new tab has no in-memory state, the surface must be reachable by id: either pin it first (POST /api/v1/ui/surfaces → surface_id; the new tab does GET /api/v1/ui/surfaces/{surface_id} and renders envelope) or stash the envelope in sessionStorage/IndexedDB (ChatService already persists messages) under a local id and fall back to the pinned id when present. Pinning is the recommended default: it makes the dashboard bookmarkable, shareable and refreshable for free. Set metadata.extensions.parrot_theme/parrot_layout from the app theme before pinning.
Button 2 — interactive HTML in a new browser tab. Requires the pinned surface_id. Fetch GET /api/v1/ui/surfaces/{surface_id}?format=html with the bearer header (a plain <a target=_blank> to the API cannot carry the Authorization header unless cookie auth is active), then:
const html = await res.text();
const url = URL.createObjectURL(new Blob([html], { type: "text/html;charset=utf-8" }));
const tab = window.open(url, "_blank", "noopener");
setTimeout(() => URL.revokeObjectURL(url), 60_000);
window.open must be called synchronously inside the click handler to survive popup blockers: open a blank tab first (const tab = window.open("", "_blank")), then set tab.location = url after the fetch resolves (the InfographicCanvas.handleSave / handlePrint code already uses this pattern). When cookie auth is active (apiWithCredentials), a direct window.open(\${config.apiBaseUrl}/api/v1/ui/surfaces/${id}?format=html`)also works. For an in-app preview instead of a tab, reuse theInteractiveArtifactCanvasiframe recipe:srcdoc,sandbox="allow-scripts allow-forms allow-modals allow-popups"(noallow-same-origin),referrerpolicy="no-referrer"`.
Pin is what makes both buttons durable: POST /api/v1/ui/surfaces {kind, title, envelope: surface, agent_id, session_id, recipe_name?, recipe_params?} → store surface_id on the message (ChatService.saveMessage). Auto-pin dashboards on arrival; make it explicit for widgets.
8.4 The renderer engine (a2ui-engine.svelte.ts)¶
export class A2uiSurface {
surfaceId: string;
catalogId: string;
components = $state.raw<Map<string, A2uiComponent>>(new Map());
dataModel = $state<Record<string, unknown>>({});
sendDataModel = false;
degraded: DegradationRecord[] = [];
constructor(cs: CreateSurface) { /* index by id, assert root exists, snapshot dataModel */ }
resolve<T>(value: Dynamic<T>, scope?: string): T // literal | {path} | {call} — §4.5 detection rule; relative pointer → scope
applyUpdate(msg: UpdateDataModel | UpdateComponents) // pointer set/delete; component upsert
children(node): A2uiComponent[] // static list, or ChildTemplate expansion per §4.6 (clone subtree, suffix ids, set scope)
dispatchAction(node, event: EventAction): ActionMessage // resolve context/userMessage, attach dataModel if sendDataModel
}
Design rules:
$state.rawfor the component map,$statefor the data model. Components are replaced wholesale onupdateComponents; the data model is mutated at pointers and Svelte's deep reactivity re-renders exactly the bound components.- Resolve at render time, not at load time (a live renderer must not bake). Cache pointer lookups per render pass if profiling shows the need.
- Intercept
Chart,DataTable,Map,KPICard,Infographic,Report,FilterBarand render natively; forInfoCardandTimelineeither native or lowered — both are cheap. Unknown component or unknown catalog →Degraded.sveltewith the original id, and push{id, component, reason}todegraded. - Recursion is by id:
<A2uiNode {surface} id="root" />looks up the component, picks the Svelte component from the registry, and passessurface,node,scope. Children render through the sameA2uiNode, soRow/Column/Card/Tabsnever import concrete leaf components. - Infographic sections use the
{component, properties}descriptor shape, not the wire shape: when renderingInfographic.sections[i].components[j], spreadpropertiesonto a synthetic node with a deterministic id (${root.id}-s${i}-c${j}) before handing it to the registry. - Chart / Map / DataTable feed existing components:
Chart→AppChart.sveltewith anAppChartConfigbuilt from the props androws = resolve(data);Map→StructuredMap.svelte;DataTable→DataTable.svelte(or a shadcn data-table) withcolumnsfrom the typed column list. Lazy-import these exactly likeChatBubble.sveltedoes so the global FloatingChat does not pull ECharts/Leaflet. - Inputs write back:
TextField,CheckBox,ChoicePicker,Slider,DateTimeInputbind todataModelat theirvalue.path(two-way throughresolve+ asetAtPointer). That is what makesButton.action.event.contextbindings andcheckswork. - Checks: evaluate
checks[]on change; render theValidationResult.message(fallbackCheckRule.message) under the input;severitymaps ontoAlertvariants. - Theme: read
metadata.extensions.parrot_themeas a hint only; the renderer uses the app's semantic tokens (--card,--muted,--primary, …). Introduce--agent-dashboard-*slots only if needed, and if you do, add them tosrc/lib/styles/themes/_schema.cssand all four theme files orpnpm checkfails. - Markdown in
Text:markdownToHtml(text)— it already sanitizes with DOMPurify. Never{@html}unsanitized wire text.
8.5 API client (src/lib/api/a2ui.ts)¶
const A2UI = (agent: string) => `/api/v1/agents/${agent}/a2ui`;
const SURFACES = "/api/v1/ui/surfaces";
export const a2uiApi = {
capabilities: (agent) => http.get(`${A2UI(agent)}/capabilities`),
send: (agent, sessionId, envelopes: RendererMessage[]) =>
http.post(A2UI(agent), envelopes.length === 1 ? envelopes[0] : envelopes, { params: { session_id: sessionId } }),
// → one AgentMessage (Content-Type application/a2ui+json) or {messages: AgentMessage[]}; 400 carries an error envelope
stream: (agent, sessionId, onMessage, signal) => sseReader(`${config.apiBaseUrl}${A2UI(agent)}?session_id=${sessionId}`, getAuthHeaders(), onMessage, signal),
// fetch-based SSE reader: split on "\n\n", ignore lines starting with ":", JSON.parse after "data: "
listSurfaces: (kind?) => http.get(SURFACES, { params: kind ? { kind } : {} }),
getSurface: (id, share?) => http.get(`${SURFACES}/${id}`, { params: { format: "json", ...(share && { share }) } }),
getSurfaceHtml: (id, share?) => fetch(`${config.apiBaseUrl}${SURFACES}/${id}?format=html${share ? `&share=${share}` : ""}`, { headers: getAuthHeaders() }).then(r => r.text()),
pin: (body: PublishSurfaceRequest) => http.post(SURFACES, body), // 201 {surface_id}
refresh: (id, params, share?) => http.post(`${SURFACES}/${id}/refresh`, { params }, { params: share ? { share } : {} }),
share: (id, opts: { expires_at?: string; ttl?: boolean }) => http.post(`${SURFACES}/${id}/share`, opts),
revoke: (id, token) => http.delete(`${SURFACES}/${id}/share/${token}`),
remove: (id) => http.delete(`${SURFACES}/${id}`),
};
Use the shared Axios singleton ($lib/api/http.ts) so the bearer interceptor and the ApiError mapping apply; use raw fetch only for the SSE stream and the HTML body. Remember that a 401 that does not mention "policy" clears the token and redirects to /login in the interceptor.
8.6 Actions and agent functions from the renderer¶
- A
Button(or any component) withaction.event→surface.dispatchAction(node, event)builds theactionenvelope (§4.3) and posts it viaa2uiApi.send. The response may contain a newcreateSurface(the agent answered the turn with a surface) — treat it like a chat response: render it in place or as a new message. action.functionCallwithcall: "openUrl"→window.open(args.url, "_blank", "noopener"); any other Basic function → evaluate locally; a Parrot/agent function →callAgentFunctionwithcatalogIdalways set to the surface catalog.callAgentFunctionresponses:agentFunctionResponse.value(JSON-safe) or anerrorenvelope. Forrefresh_dashboardthe value is an artifact reference — follow up withgetSurface/refreshto obtain the new envelope.- Keep one SSE connection per
(agent, session)while a surface is mounted; oncallRendererFunctionevaluate the named function (Basic functions, or your own renderer functions) and answer withrendererFunctionResponseechoingfunctionCallId. - Surface state: before calling
refresh_dashboardwith no explicit args, push the current filters with anactionwhosedataModelis{filters: {...}}so the tool reportsfilter_source: "surface_state".
8.7 Pinned-surfaces sidebar and sharing¶
GET /api/v1/ui/surfaces?kind=dashboard for the sidebar (access: owner|shared, refreshable, updated_at); refresh button only when refreshable; share dialog → POST …/share → copy ${appUrl}/…/surface/${surface_id}?share=${token} (the target page forwards ?share= to getSurface/getSurfaceHtml); revoke via DELETE …/share/{token}. A 410 on open means the share was revoked or expired; 404 means unknown or not yours.
8.8 Testing¶
Vitest (src/**/*.test.ts, jsdom for component tests). Unit-test the engine against the fixtures in §10: pointer resolution (absolute, relative, ~0/~1, missing key → error unless parrot_optional), ChildTemplate expansion (id suffixing, @index), updateDataModel delete-on-null, formatString grammar, the FilterBar scoping rule (a section without the column stays untouched), and the error envelope shapes. Component-test KPICard, Chart (config mapping only), DataTable (typed formatting), Infographic (sections → tabs). Run pnpm check, pnpm test, pnpm build, prettier --write . before committing.
9. TypeScript wire types¶
Drop-in for src/lib/types/a2ui.ts (derived from models.py and the vendored schemas):
export type A2uiVersion = "v1.0";
export type JsonPointer = string; // RFC 6901; relative (no leading "/") inside template scope
export interface DataBinding { path: JsonPointer } // the ONLY key
export interface FunctionCall { call: string; args?: Record<string, unknown>; catalogId?: string }
export type Dynamic<T> = T | DataBinding | FunctionCall;
export type DynamicString = Dynamic<string>; export type DynamicNumber = Dynamic<number>;
export type DynamicBoolean = Dynamic<boolean>; export type DynamicStringList = Dynamic<string[]>;
export interface ChildTemplate { componentId: string; path: JsonPointer }
export type ChildList = string[] | ChildTemplate;
export interface EventAction { name: string; userMessage?: DynamicString; context?: Record<string, Dynamic<unknown>> }
export type Action = { event: EventAction; functionCall?: never } | { functionCall: FunctionCall; event?: never };
export interface CheckRule { condition: FunctionCall | DataBinding; message?: string }
export interface ValidationResult { valid: boolean; code?: string; message?: string; severity?: "error" | "warning" | "info" }
export interface Accessibility { label?: string; description?: string; live?: "off" | "polite" | "assertive"; hidden?: boolean }
export interface Extensions {
parrot_variant?: "chart" | "table" | "kpi" | "card" | "map" | "timeline" | "report" | "infographic" | "filter-bar";
parrot_role?: string; parrot_unit?: string | null; parrot_trend?: "up" | "down" | "flat" | null;
parrot_total_rows?: number; parrot_truncated?: boolean; parrot_series_data?: DataBinding; parrot_layer_data?: DataBinding;
parrot_filter_column?: string; parrot_component_id?: string; parrot_optional?: JsonPointer[];
parrot_theme?: "light" | "dark" | "corporate" | "midnight" | "petrol"; parrot_layout?: "analytics" | "report" | "print";
[key: string]: unknown;
}
export interface ComponentMetadata { extensions?: Extensions }
export interface A2uiComponent {
id: string; component: string; catalogId?: string;
child?: string; children?: ChildList; weight?: number;
accessibility?: Accessibility; checks?: CheckRule[]; action?: Action; metadata?: ComponentMetadata;
[prop: string]: unknown; // catalog props are top-level
}
export interface CreateSurface { surfaceId: string; catalogId?: string; sendDataModel?: boolean; components: A2uiComponent[]; dataModel?: Record<string, unknown>; metadata?: ComponentMetadata }
export interface UpdateComponents { surfaceId: string; components: A2uiComponent[] }
export interface UpdateDataModel { surfaceId: string; path?: JsonPointer; value: unknown } // value null = delete
export interface DeleteSurface { surfaceId: string }
export interface CallRendererFunction { functionCallId: string; callFunction: FunctionCall & { catalogId: string } }
export interface FunctionCallError { code: string; message: string }
export type AgentFunctionResponse = { functionCallId: string; value: unknown; error?: never } | { functionCallId: string; error: FunctionCallError; value?: never };
export type ErrorMessage =
| { code: "VALIDATION_FAILED" | "UNALLOWED_PARENT" | "UNALLOWED_CHILD"; surfaceId: string; path: string; message: string }
| { code: string; surfaceId: string; functionCallId?: never; message: string }
| { code: string; functionCallId: string; surfaceId?: never; message: string };
export type AgentMessage = { version: A2uiVersion } & (
| { createSurface: CreateSurface } | { updateComponents: UpdateComponents } | { updateDataModel: UpdateDataModel }
| { deleteSurface: DeleteSurface } | { callRendererFunction: CallRendererFunction }
| { agentFunctionResponse: AgentFunctionResponse } | { error: ErrorMessage }); // error is returned to renderers even though the A→R schema omits it
export interface ActionMessage { name: string; userMessage?: string; surfaceId: string; sourceComponentId: string; timestamp: string; context: Record<string, unknown>; metadata?: ComponentMetadata; dataModel?: Record<string, unknown> }
export interface CallAgentFunction { surfaceId: string; functionCallId: string; callFunction: FunctionCall }
export type RendererFunctionResponse = AgentFunctionResponse;
export type RendererMessage = { version: A2uiVersion } & (
| { action: ActionMessage } | { callAgentFunction: CallAgentFunction } | { rendererFunctionResponse: RendererFunctionResponse } | { error: ErrorMessage });
export type A2uiEnvelope = AgentMessage;
export type SurfaceKind = "dashboard" | "infographic" | "widget";
export interface SurfaceMetadata { surface_id: string; kind: SurfaceKind; title: string; refreshable: boolean; created_at: string; updated_at: string; catalog_id: string | null; agent_id: string; access?: "owner" | "shared" }
export interface SurfaceResponse { status: "success"; envelope: CreateSurface; metadata: SurfaceMetadata }
export interface SurfaceListResponse { status: "success"; count: number; surfaces: SurfaceMetadata[] }
export interface PublishSurfaceRequest { kind: SurfaceKind; title: string; envelope?: CreateSurface; source_artifact_id?: string; agent_id?: string; session_id?: string; recipe_name?: string; recipe_owner?: string; recipe_params?: Record<string, unknown> }
export interface ShareResponse { status: "success"; token: string; expires_at: string | null; permissions: "read+refresh" }
export interface ApiErrorBody { status: "error"; message: string; errors?: unknown[]; refreshable?: boolean }
export interface AgentArtifact { type: "chart" | "table" | "map"; artifactId: string; surfaceId?: string; schemaVersion?: 2; definition: A2uiComponent | Record<string, unknown> }
export interface Capabilities { "v1.0": { supportedCatalogIds: string[]; acceptsInlineCatalogs: false } }
export interface DegradationRecord { id: string; component: string; reason: string }
Parrot component prop interfaces (ChartProps, DataTableProps, MapProps, KPICardProps, InfoCardProps, TimelineProps, ReportProps, InfographicProps, FilterBarProps) follow §5.2 verbatim; the existing AppChartConfig (chart-contract.ts) and StructuredMapConfig types already cover Chart and Map.
10. Conformance fixtures and testing¶
Schemas to vendor (copy into the frontend, e.g. src/lib/components/agents/canvas/a2ui/schema/): packages/ai-parrot/src/parrot/outputs/a2ui/catalog/basic/spec/{agent_to_renderer,renderer_to_agent,common_types,catalog,catalog_definition,agent_capabilities}.json. The Parrot catalog document is generated, not stored — regenerate it with:
source .venv/bin/activate
python -c "from pathlib import Path; from parrot.outputs.a2ui.catalog.export import write_catalog_definition; write_catalog_definition(Path('a2ui-parrot-catalog.json'))"
Golden lowered trees (byte-stable, component_id un-aliased because they are model dumps): packages/ai-parrot/tests/outputs/a2ui/golden/{chart,datatable,infocard,infographic,kpicard,map,report,timeline,filterbar}_lowered.json.
Real end-to-end envelopes: artifacts/a2ui_dashboard/02_envelope_v1.json (Infographic with charts + tables + dataModel), 06_live_envelope.json, 03_baked_components.json (templates expanded, zero bindings — tests the post-bake path), artifacts/a2ui_deterministic_refresh/03_capabilities.json (a real exported function map). Run python examples/agents/a2ui/flex_dashboard_demo.py --serve (port 8092) to produce 01_dashboard_default.html, 02_dashboard_2025-10_field-time.html, 03_capabilities.json for the Flex dashboard offline — the same HTML the surfaces route serves. InMemorySurfaceStore / InMemoryPendingCalls in that demo are minimal reference implementations useful for mocking the RPC leg in frontend tests.
Backend conformance suite (the authoritative "is this legal on the wire" gate): packages/ai-parrot/tests/outputs/a2ui/conformance/test_all_emitters.py (every builder, adapter, recipe layout, bake output and renderer input validated two ways), test_runtime_envelopes.py (every RPC envelope), test_benchmark.py. Renderer tests: packages/ai-parrot-visualizations/tests/outputs/a2ui_renderers/ (test_interactive_html.py, test_filterbar_interactive.py, test_rich_datatable.py, test_semantic_classes.py, test_document_shell.py).
Postman: docs/postman/a2ui-agentdashboard.postman_collection.json — variables baseUrl, token, agentId, sessionId, surfaceId, shareToken, artifactId, recipeName, deeplinkToken; the "Pin surface" request captures surface_id and the "Mint share" request captures token into collection variables automatically.
11. Known gaps, bugs and doc/code disagreements¶
Verified on dev @ a1eca82b4. Items marked backend need a backend fix; the rest are things the frontend must tolerate.
interactive-htmlcannot be resolved from a cold registry (backend). The renderer registers as"interactive-html"(hyphen) while its module isinteractive_html.py;get_a2ui_renderer("interactive-html")raisesImportErrorunless the module was already imported in the process, andget_a2ui_renderer("interactive_html")fails because that name is not registered.RenderSpec.profiledefaults to"interactive-html", soPOST /api/v1/infographic_recipes/{name}/runandrefresh_dashboardfail on a fresh worker until something imports the module (the ui_surfaces HTML route does, function-locally). Reproduced with a one-line script this session. Fix: eager-import ina2ui_renderers/__init__.py, or register an alias.A2UIHandlerdoes not re-apply@is_authenticated()(backend, security review needed). It overridespost/get, so the decorators applied toAgentTalk's methods are not inherited; its only gate is the 401 on an unresolvable user, anduser_idis accepted from the?user_id=query string. Whether the global auth middleware rejects unauthenticated requests before the handler was not verified in this session — confirm before exposing the route publicly.- HTML surface response lacks
charsetand CSP headers (backend):SurfaceNegotiationService._respond_htmlreturnsweb.Response(body=…, content_type="text/html")— unlike every other HTML path (agent.py,infographic.py,artifacts.py). Non-ASCII content relies on the document's<meta charset>; the response is iframe-embeddable with noframe-ancestorsrestriction. - Narrative never reaches the Flex dashboard envelope (backend, documented in
agents/flex_dashboard.py:549-575):RecipeRunner._assemble_envelope_or_raisedropslayout.metadata(andparrot_optional) on the Infographic path, so any layout binding to an absent/narrativeraisesBakeError; the recipe therefore does not bind it. callFunction.catalogIdis effectively mandatory forcallAgentFunction: the dispatcher resolves the catalog from the call'scatalogIdor a previously persisted surface state and never falls back to the runtime default. Always send it.requiresUserActivationis not enforced at dispatch:export_functions()advertises such tools asrendererOnly, butToolManagerExecutor.list_functions()reports every tool asrendererOrAgentand dispatch checks the latter. Enforce user activation in the renderer.erroris not on the agent→renderer schema, yet the runtime returnserrorenvelopes to renderers. Accept it.- Persisted
surface_id≠ envelopesurfaceId: pinning always mints a fresh uuid4 (recipe envelopes use<recipe>-infographic, not a UUID). Use the stored id for the surfaces API and the envelope id for RPC messages. - Chat error bodies may be raw strings under
Content-Type: application/json(Missing Agent Name,Agent 'x' not found.,Error retrieving agent: …) becauseBaseView.error(<str>)does not JSON-encode; and its status whitelist (400/401/403/404/406/412/428) silently downgrades other codes to400. The ui_surfaces and A2UI handlers build their responses directly and are not affected. ?output_mode=query parameter is dead on the chat endpoint (_get_output_mode()is never called); only the body field works.- Streaming is chunked, not SSE, on the chat endpoint; the only SSE route is
GET /api/v1/agents/{agent_id}/a2ui. - Surface-state concurrency is process-local (
asyncio.Lockper session insideConversationMemorySurfaceStore); a multi-worker deployment can race the same session. Pending renderer calls expire after 900 s; surface state has no TTL. refresh_dashboardis not registered by default onFlexDashboard— the backend must callagent.build_refresh_tool(pctx)with a realPermissionContext(RecipeRunner.runfails open on a falsy one). Check…/a2ui/capabilities-adjacent function export before showing a "Refresh via agent" control; prefer the ui_surfacesrefreshroute.- Docs drift:
docs/agent.mddocuments a non-existent barePOST /api/v1/agents/chat/route, a{success, content}response shape that is dead code, andmcp_serversin the POST body (stripped; usePATCH); it never mentionsoutput_mode,a2ui, streaming, or HITL.Fixed by FEAT-527 — the path and both routes are now correct there.docs/infographic_handler_api.mdpoints atpackages/ai-parrot/…/handlers/infographic.py(the file is inai-parrot-server) and omits the/renderand/render/jobs/{id}routes.sdd/specs/flex-agent-infographic-a2ui.spec.mdsays datasets are registered withadd_dataset(query_slug=…); the code usesadd_query(deliberately, to avoid eager fetches).handlers/artifacts.pydocuments the public route as{artifact_id}.html; the registered pattern is{artifact_id_html}with the suffix stripped in code. - No
docs/outputs/page exists for FEAT-492 (surfaces); the spec and source are the only documentation besides this file. AGENTS.mdin the frontend is stale (claims Flowbite, Playwright,svelte-echarts, Chart.js — none are dependencies);CLAUDE.md§"Chat System" and §"API Client Layer" are accurate. There is no shadcntable,tabs,tooltip,dropdown-menuorscroll-areadirectory yet —AppTabs,AppTooltip,AppDropdown(bits-ui wrappers) andDataTable.svelte/SimpleTableare the current substitutes.