Dynamic UI
Agents compose a form page from a short JSON description and use one session in the conversation (MCP Apps), a browser, the CLI, or plain text
Dynamic UI lets an agent stop asking for parameters one by one and instead compose a page for the user's goal from pre-built components: for "make an ad image" the page shows the product image, brand, selling points, placement, style, model, cost estimate, and a generate button. The agent only writes a few KB of JSON; the renderer is compiled ahead of time, so opening a page generates no code and calls no model.
- One set of components: ad images, product shots, image-to-video pages and anything else are composed from the same components and rendered by the same renderer. They are not fixed templates.
- One session: MCP, the CLI and the browser read and write the same server-side session, so the agent can read what the user entered and the task results.
- No accidental charges: creating or updating a page, choosing a model and editing fields never generate anything. Paid tasks start only when the user clicks a generate button (or the agent calls
run_ui_actionafter the user explicitly agreed), and one click is never charged twice.
Four ways in
| Client capability | Entry | What the user sees |
|---|---|---|
| Supports MCP Apps | The MCP tool result carries the UI | The page inline in the conversation |
| MCP without MCP Apps | browser_url in the tool result | The same page in a browser |
| No MCP, can run commands | The eggapi CLI | The agent creates the page by command and shares the link |
| No browser, SSH, headless | get_ui_state lists fields, options and missing values | The agent asks in chat, then calls set_ui_values and run_ui_action |
The browser page is a full entry point with the same features as the inline UI, including image upload. Tool results always carry text, structured data and the browser link, so clients without UI support can complete the whole flow.
Client support
As of 2026-10-28. "Docs only" means based on the client's official documentation, not run in that client; "Tested" means covered by this repository's automated tests.
| Client | MCP connection | UI in the conversation | Status |
|---|---|---|---|
| Claude Code | HTTP or stdio | Its docs do not say the terminal renders MCP Apps; use the browser link | Setup docs only, not run in the client |
| Codex CLI | HTTP or stdio | Its docs do not mention MCP Apps; use the browser link | Setup docs only, not run |
| Gemini CLI | HTTP or stdio | Its docs describe text, image and audio content only; use the browser link | Setup docs only, not run |
| OpenCode | HTTP or stdio | Its docs do not mention MCP Apps; use the browser link | Setup docs only, not run |
| Claude / Claude Desktop, ChatGPT, VS Code GitHub Copilot, Goose, ... | HTTP | Listed as MCP Apps hosts in the MCP Apps docs | Not run in these hosts |
| Official Go MCP SDK client | HTTP | — | Tested: tools, resource, capability detection, error structure |
| Simulated MCP Apps host | postMessage | Yes | Tested: handshake, theme, narrow width, keyboard, submit and results (a simulated host is not a real host) |
Setup
Create an API key in the dashboard and export it in the terminal that starts your agent:
export EGGAPI_API_KEY="egg_..."
The remote MCP endpoint is https://api.eggapi.ai/mcp (Streamable HTTP, Authorization: Bearer <key>). Clients already connected to EggAPI MCP need no change: the new tools appear automatically. Clients without HTTP MCP can use eggapi mcp over stdio, which forwards messages to the same endpoint unchanged.
Claude Code
claude mcp add --transport http eggapi https://api.eggapi.ai/mcp \ --header "Authorization: Bearer $EGGAPI_API_KEY"
Over stdio:
claude mcp add --env EGGAPI_API_KEY=$EGGAPI_API_KEY eggapi -- eggapi mcp
Codex CLI
codex mcp add eggapi --url https://api.eggapi.ai/mcp --bearer-token-env-var EGGAPI_API_KEY
Or in ~/.codex/config.toml:
[mcp_servers.eggapi] url = "https://api.eggapi.ai/mcp" bearer_token_env_var = "EGGAPI_API_KEY"
Gemini CLI
gemini mcp add --transport http --header "Authorization: Bearer $EGGAPI_API_KEY" eggapi https://api.eggapi.ai/mcp
Or in ~/.gemini/settings.json (httpUrl is Streamable HTTP; url means SSE):
{
"mcpServers": {
"eggapi": {
"httpUrl": "https://api.eggapi.ai/mcp",
"headers": { "Authorization": "Bearer <your API key>" }
}
}
}OpenCode
In the project's opencode.json or ~/.config/opencode/opencode.json:
{
"mcp": {
"eggapi": {
"type": "remote",
"url": "https://api.eggapi.ai/mcp",
"headers": { "Authorization": "Bearer {env:EGGAPI_API_KEY}" }
}
}
}For stdio, use { "type": "local", "command": ["eggapi", "mcp"], "environment": { "EGGAPI_API_KEY": "egg_..." } } instead.
Clients that support MCP Apps
Add EggAPI as described in Connect AI agents. Clients that only take a URL use the personal link from the dashboard, https://api.eggapi.ai/mcp/k/<your key>.
Optional: skill
eggapi-dynamic-ui is a thin skill that only tells the agent when to use dynamic UI and in which order to call the tools. Everything works without it; install it like the workflow skills.
Agent workflow
- Call
get_ui_catalogonce for the component contract. Passexample(ad_image,product_main_image,image_to_video) for a reference page. - Choose the fewest suitable components, write the page description and call
render_ui. Usevaluesto pre-fill what the conversation already tells you. - Give the user
browser_url. Clients that support MCP Apps also show the page inline. - When the user says they are done, call
get_ui_statefor their values, missing fields, cost estimate and task results. - When the user wants the page changed ("add a copy field", "switch to a video ad", "hide the model choice"), call
update_uiwith the complete new description andbase_revision. - Call
run_ui_actiononly after the user explicitly agreed to the estimated cost, and pass the amount they agreed to asexpected_cost_usd.
The agent never writes HTML, CSS or React, installs dependencies, starts servers or runs browser tests.
Page description (UI Schema 1.0)
{
"schema_version": "1.0",
"page": { "id": "image_to_video", "title": "Image to video" },
"initial_values": { "aspect_ratio": "9:16", "duration": 5 },
"actions": [
{
"id": "animate",
"type": "generate",
"params": {
"model": { "field": "model" },
"prompt": { "field": "motion" },
"images": { "field": "first_frame" },
"aspect_ratio": { "field": "aspect_ratio" },
"resolution": { "field": "resolution" },
"duration": { "field": "duration" }
}
}
],
"root": {
"id": "root",
"type": "stack",
"children": [
{ "id": "first_frame", "type": "image_input", "props": { "label": "First frame", "required": true } },
{ "id": "motion", "type": "textarea", "props": { "label": "Motion", "required": true } },
{ "id": "aspect_ratio", "type": "aspect_ratio_picker", "props": { "label": "Aspect ratio", "options": ["9:16", "16:9", "1:1"] } },
{ "id": "duration", "type": "number_input", "props": { "label": "Duration", "required": true, "min": 1, "max": 600, "unit": "s" } },
{ "id": "model", "type": "model_picker", "props": { "label": "Video model", "required": true, "kind": "video", "generation_types": ["image-to-video"] } },
{
"id": "resolution",
"type": "select",
"visible_when": { "field": "model", "op": "model_has_resolutions" },
"props": { "label": "Resolution", "options_source": { "source": "model_resolutions", "model_field": "model" } }
},
{ "id": "cost", "type": "cost_estimate", "props": { "action": "animate" } },
{ "id": "generate", "type": "generate_button", "props": { "action": "animate", "label": "Generate video", "confirm": true } },
{ "id": "status", "type": "task_status", "props": { "action": "animate" } },
{ "id": "preview", "type": "video_preview", "props": { "action": "animate" } }
]
}
}| Field | Meaning |
|---|---|
schema_version | Required; only "1.0" is supported. Other versions return unsupported_schema_version. |
page | id (lowercase letters, digits, underscores), title, optional description. |
root | Root of the component tree; must be a layout component. |
initial_values | Starting values keyed by field id, checked against each field's type. |
actions | Pre-registered actions and their parameter bindings, at most 8. |
Every component node has a stable id, a type and props, and optionally visible_when and children. Input components edit the field named by field, which defaults to the id. Keep ids unchanged when updating a page and the user's input is preserved.
Limits: at most 64 KB of JSON, 150 components, 10 levels of nesting and 4 levels of condition nesting. A page description is data only: no HTML, scripts, code URLs or backend tool names, and components and actions can only reference names in the catalog.
Components
| Component | Category | Value | Main props |
|---|---|---|---|
stack | layout | — | gap (sm/md/lg), direction (vertical/horizontal) |
grid | layout | — | columns (1-4, one column on narrow screens), gap |
section | layout | — | title, description |
text_input | input | string | label, placeholder, help, required, max_length |
textarea | input | string | as above, plus rows |
select | input | string | options ([{value,label}]) or options_source, exactly one |
segmented_control | input | string | options (2-6) |
number_input | input | number | min, max, step, integer, unit |
image_input | input | image_list | max_images (1-8); upload in the browser, image links elsewhere |
model_picker | business | string | kind (image/video), generation_types, show_price; models and prices come from the backend |
aspect_ratio_picker | business | string | options (subset of 1:1, 3:4, 4:3, 9:16, 16:9, 21:9) |
cost_estimate | business | — | action; live prices, same formula as the server's balance hold |
generate_button | business | — | action, label, confirm; the only component that submits paid work |
task_status | business | — | action; queued/running/done/failed, elapsed time, actual cost, no invented progress percentage |
image_gallery | business | — | action, columns |
video_preview | business | — | action |
The full prop types, defaults and ranges are whatever get_ui_catalog (or GET https://api.eggapi.ai/v1/ui/catalog) returns; it reads the same registry the server validates with.
Conditions
visible_when shows or hides a component. Hidden fields are neither required nor submitted.
{ "field": "background", "op": "eq", "value": "custom" }
{ "all": [ { "field": "mode", "op": "eq", "value": "video" }, { "field": "model", "op": "model_requires_image" } ] }| Operator | Value | Meaning |
|---|---|---|
eq, neq | scalar | equals, differs |
in, not_in | list of scalars | one of, none of |
empty, not_empty | none | filled in or not |
gt, gte, lt, lte | number | number_input fields only |
model_kind_eq | image or video | kind of the selected model |
model_generation_type_in | list of generation types | generation type of the selected model |
model_requires_image | none | the selected model needs an input image |
model_has_resolutions | none | the selected model is priced per resolution |
Conditions combine only with all, any and not; there are no expressions or scripts.
Data sources
options_source of a select currently supports {"source": "model_resolutions", "model_field": "<model field>"}: the resolutions the selected model is priced for, cheapest first. When the model declares none, the list is empty and the model's default is used.
Actions
The only action type is generate; it creates an image or video task depending on the selected model. Each parameter takes one binding:
| Binding | Example | Meaning |
|---|---|---|
field | {"field": "model"} | a field's value; the component must be compatible |
value | {"value": "bytedance/seedance-2.5/image-to-video"} | a constant, e.g. a fixed model after "hide the model choice" |
template | {"template": "Brand: {brand}\nSelling points: {selling_points}"} | combines fields into text; a line with an empty placeholder is dropped; {{ and }} are literal braces |
| Param | Required | Meaning |
|---|---|---|
model | yes | model_picker field or constant |
prompt | yes | text field, template or constant |
negative_prompt | no | as above |
images | no | image_input field; references for image models, first frame (one image) for video models |
aspect_ratio | no | ratio picker, select or constant (default 16:9) |
resolution | no | must be a resolution the model is priced for |
num_outputs | no | 1-16, image models only |
duration | for video | 1-600 s, billed per second; the backend publishes no per-model range, so none is assumed |
On submit the server checks the inputs against the selected model: image-to-* models need an image, text-to-* models refuse images, video models take one first frame, and the resolution must be priced.
Errors
Every validation problem comes with a JSON pointer path, a code and a hint, so an agent can fix it directly:
{
"error": {
"code": "SCHEMA_INVALID",
"issues": [
{ "path": "/root/children/2/type", "code": "unknown_component", "message": "unknown component \"html\"", "hint": "allowed: stack, grid, ..." }
]
}
}| Code | Meaning |
|---|---|
SCHEMA_INVALID | invalid page description; issues lists each problem |
REVISION_CONFLICT | the page changed since you read it; the error carries the current session |
ACTION_NOT_READY | required values missing, not valid for the model, or the estimate changed (estimate_changed); issues points at the fields |
TOO_MANY_SESSIONS | an account keeps at most 200 unexpired sessions; reuse a page with update_ui |
INSUFFICIENT_BALANCE, CONTENT_BLOCKED | not enough balance, or blocked by the NSFW filter; nothing is charged |
NOT_FOUND | the session does not exist, expired, or belongs to another account |
Sessions and updates
The server keeps one session per page: session_id, schema_version, revision (bumped on every change), schema_revision (the revision of the last page description change), fingerprint (changes with the revision and with live model prices), the page description, values, submissions, status and expiry. A session belongs to the EggAPI account that created it and expires after 7 days without changes; expired sessions are deleted the next time that account creates a page, and an account keeps at most 200 unexpired sessions.
update_ui rules:
base_revisionmust equal the currentschema_revision, otherwise you getREVISION_CONFLICTand nothing is overwritten. The user editing values meanwhile is not a conflict.- A value is kept when its field id and value type are unchanged and it is still valid for the new field (
reconcile.preserved). - When the type changed or the value is no longer valid (a removed option, an image model after switching to a video picker) it is reset (
reconcile.reset); removed fields are listed with their previous value inreconcile.removed. Stale values are never submitted silently. - An open page picks up the new version within seconds and updates in place: text being typed and focus are kept.
Browser page
browser_url looks like https://eggapi.ai/ui/<session_id>. It contains no API key; opening it requires signing in to the same EggAPI account, so it works on any device. Links created by agents on a laptop, a remote server, over SSH or in a container all point at the EggAPI website, not at the agent machine's localhost. Without a GUI, copy the link to any browser; the CLI never opens one.
The page saves input automatically and checks for updates every few seconds; while a task runs it refreshes at the task API's polling limits (5 s for images, 10 s for videos), without the agent polling.
When the user submits in the browser, the result is written to the session, but a browser cannot wake an agent in a terminal. Tell the agent you are done and it reads the result with get_ui_state. MCP Apps hosts that support ui/update-model-context also receive the latest state in the agent's context.
CLI
eggapi is a standard-library-only command-line tool that shares the server's validation, sessions and account scoping with MCP. There are no prebuilt releases yet; build it from source with repository access (Go 1.23+):
git clone https://github.com/danaigc/eggapi && cd eggapi/backend go build -o ~/.local/bin/eggapi ./cmd/eggapi
| Command | Does |
|---|---|
eggapi ui catalog --json | the component contract; --example ad_image returns a reference page |
eggapi ui create --schema-file page.json --json | create a page; - reads stdin |
eggapi ui update <id> --schema-file page.json --base-revision N --json | update a page |
eggapi ui state <id> --json | values, missing fields, actions and task results; --full adds the page description |
eggapi ui set <id> --values '{"brand":"Acme"}' --json | set field values |
eggapi ui run <id> <action> --schema-revision N --expected-cost USD --submission-id S --json | run the paid action; refused if the estimate differs from --expected-cost; retry with the same --submission-id and it is never charged twice (network or server errors return that id) |
eggapi ui wait <id> --json | wait for the latest task at 5/10 s intervals |
eggapi mcp | MCP server over stdio |
With --json, stdout holds exactly one JSON document and logs go to stderr. Errors print {"error": {"code", "message", "http_status", "details"}}. Exit codes: 0 ok, 2 usage, 3 rejected (validation, conflict, balance, ...), 4 authentication, 5 network or server, 6 wait timeout. Credentials come from EGGAPI_API_KEY; EGGAPI_API_BASE overrides the API URL.
Without a GUI
Without a browser, the agent completes the same flow in text:
- Create the page with
render_ui(or reuse one). get_ui_statereturns each field'slabel, whether it is required, missing and visible, its value, and theoptionsit currently allows (models, resolutions, ratios, ...).- Ask the user in chat and write the answers with
set_ui_values; rejected values come back with reasons. - Show the user
actions[].estimated_cost_usdand callrun_ui_actiononly after they agree, withexpected_cost_usdset to the amount they agreed to. - Read the task status, output URLs and actual cost later with
get_ui_state.
Billing and security
- The estimate uses the same formula as the balance hold taken when a task is created: per image or per second of video, at the model's resolution price. On submit the server recomputes it from the actual parameters; the task is settled at its real cost and refunded if it fails.
- Each click carries a
submission_id. The server deduplicates on it and derives a fixed task id from it, so retries and double clicks never create a second task. When the outcome is unknown (network failure, gateway error), the page reuses the same id on the next click and only looks up that submission. - A click submits every field value shown on screen together with the estimate on the button. If the server's estimate differs (prices changed, or values were edited elsewhere), or the submit would invalidate a chosen value (a resolution the new model does not offer), the run is refused with the reason instead of charging an amount the user did not see.
- Once generation starts it is detached from the request: closing the page or a CLI timeout cannot lose the record of a submission whose balance is held. A submission the server stopped in the middle of is completed from the task record the next time the session is read.
- Generations submitted from the browser page always use the NSFW filter, like the playground; submissions with an API key (MCP, CLI) follow the key's request.
- The API key stays in the agent's client configuration and on the server; it never appears in page descriptions, the browser page or links. The MCP Apps UI calls tools through its host; the browser page uses the website sign-in.
Limitations
- The four CLIs do not show the inline UI in the terminal (their docs say nothing about it); use the browser link or the text flow.
- The MCP Apps UI runs in the host's sandbox: it cannot upload local files (image links only; upload in the browser page), and output images or videos from domains not listed in the server's
UI_MCP_RESOURCE_DOMAINSappear as links instead of previews. - The MCP server is stateless and cannot see capabilities declared at
initialize. When a client declares MCP Apps support in each request's_meta, results sayui_mode: "embedded"; otherwise they saylinkand lead with the browser link, and hosts that support MCP Apps still render the UI. - Submitting in the browser does not notify the agent; the user tells it in the conversation.
- Aspect ratios are the six documented ones because the backend does not publish per-model support; video duration is only checked against the API's 1-600 s.
- There is no "rewrite copy / improve prompt" action yet: the agent writes copy in the conversation and puts it on the page through
valuesorupdate_ui.