{"id":49833978,"url":"https://github.com/blockful/agent-ui-session","last_synced_at":"2026-05-13T23:07:51.154Z","repository":{"id":356834542,"uuid":"1234255830","full_name":"blockful/agent-ui-session","owner":"blockful","description":"Hosted UI rendering service for terminal-bound AI agents","archived":false,"fork":false,"pushed_at":"2026-05-10T02:01:24.000Z","size":139,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-10T02:33:50.013Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/blockful.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-10T00:13:39.000Z","updated_at":"2026-05-10T02:01:28.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/blockful/agent-ui-session","commit_stats":null,"previous_names":["blockful/agent-ui-session"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/blockful/agent-ui-session","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/blockful%2Fagent-ui-session","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/blockful%2Fagent-ui-session/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/blockful%2Fagent-ui-session/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/blockful%2Fagent-ui-session/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/blockful","download_url":"https://codeload.github.com/blockful/agent-ui-session/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/blockful%2Fagent-ui-session/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33003281,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-13T13:14:54.681Z","status":"ssl_error","status_checked_at":"2026-05-13T13:14:51.610Z","response_time":115,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-05-13T23:07:50.309Z","updated_at":"2026-05-13T23:07:51.141Z","avatar_url":"https://github.com/blockful.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Pagent\n\n[![CI](https://github.com/blockful/pagent/actions/workflows/ci.yml/badge.svg)](https://github.com/blockful/pagent/actions/workflows/ci.yml)\n\nHosted UI rendering for terminal-bound AI agents. The agent emits an A2UI surface to this service, prints a short URL, and reads the user's interactions back via API.\n\n- **Live API:** https://pagent.up.railway.app\n- **Live renderer:** https://pagent.vercel.app\n\nSee [PRD.md](./PRD.md) for the design and [HANDOFF.md](./docs/HANDOFF.md) for build context.\n\n## How it works\n\nA non-technical view of what happens when your agent decides it needs a form:\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant U as You\n    participant A as Your AI agent\n    participant S as pagent\n    participant B as Browser\n\n    Note over A: A bundled skill teaches the agent \u003cbr/\u003ewhen a real form beats faking one in chat.\n    U-\u003e\u003eA: \"Ask me my favorite color via a UI\"\n    A-\u003e\u003eA: design the form\n    A-\u003e\u003eS: show_ui(spec)\n    S--\u003e\u003eA: short URL\n    A--\u003e\u003eU: prints URL in your terminal\n    U-\u003e\u003eB: open URL\n    B--\u003e\u003eU: render the form\n    U-\u003e\u003eB: fill out, submit\n    B-\u003e\u003eS: send the answer\n    A-\u003e\u003eS: check_result()\n    S--\u003e\u003eA: your answer\n    A--\u003e\u003eU: continues the conversation\n```\n\nIn plain English: the agent reads its skill, decides a real form is the right way to ask, hands a form description to the service, and prints a short URL in your terminal. You open it in the browser, fill it out, submit. The agent reads your answer back and the conversation keeps going. The agent never sees you typing — only the final result.\n\n## Layout\n\nnpm-workspaces monorepo. Three apps + the plugin scaffolding.\n\n```\napps/\n├── api/                             # REST service (Hono). Deployed on Railway.\n│   ├── server.ts\n│   ├── railway.json\n│   └── .env.example\n├── web/                             # Vite-served renderer. Deployed on Vercel.\n│   ├── index.html, main.ts\n│   ├── vite.config.ts\n│   ├── vercel.json\n│   └── .env.example\n└── mcp/                             # stdio MCP server: show_ui + check_result\n    ├── server.ts                     # source\n    ├── server.bundle.js              # esbuild output, shipped to plugin users\n    ├── smoke.mjs\n    └── .env.example\ninfra/\n└── observability/                   # self-hosted Grafana stack on Railway\n    ├── Dockerfile                    # grafana/otel-lgtm + provisioning\n    ├── dashboards/                   # Operations + Product dashboards (JSON)\n    └── provisioning/                 # Grafana datasources + dashboard provider\nskills/pagent/SKILL.md                # drop-in skill teaching the polling pattern\n.claude-plugin/plugin.json            # Claude Code plugin manifest\n.mcp.json                             # plugin's MCP server registration\n```\n\n**Observability** — see [docs/observability.md](./docs/observability.md) for\nthe self-hosted Grafana stack (metrics, traces, logs) on Railway.\n\nThe repo doubles as a Claude Code plugin and a self-hosted marketplace: `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, `skills/`, and `.mcp.json` at the repo root make it installable from GitHub with two slash commands. The skill stays at the root because Claude Code's plugin loader looks for `skills/` next to `.claude-plugin/`, even though the skill conceptually belongs to `apps/mcp/`.\n\n## Environment variables\n\nEach app validates its environment at boot/build with Zod and fails loudly on missing or malformed values — no silent defaults that bite in production. `.env.example` files in each app are the source of truth.\n\n| App                                               | Variable                   | Required?           | Validation / default                                                                                |\n| ------------------------------------------------- | -------------------------- | ------------------- | --------------------------------------------------------------------------------------------------- |\n| **api** ([`.env.example`](apps/api/.env.example)) | `DATABASE_URL`             | **always**          | Non-empty string. Boot fails with a `ZodError` otherwise.                                           |\n|                                                   | `PUBLIC_URL`               | **production**      | Valid URL. Used in `show_ui` responses.                                                             |\n|                                                   | `ALLOWED_ORIGINS`          | **production**      | Comma-separated origin list. CORS allow-list.                                                       |\n|                                                   | `PORT`                     | optional            | Coerced to number. Default `8787`. Railway sets this.                                               |\n|                                                   | `PAGE_TTL_MS`              | optional            | Coerced to number. Default `1800000` (30 min).                                                      |\n|                                                   | `RATE_LIMIT_MAX`           | optional            | Positive integer. Default `30`.                                                                     |\n|                                                   | `RATE_LIMIT_WINDOW_MS`     | optional            | Positive integer. Default `60000`.                                                                  |\n|                                                   | `NODE_ENV`                 | optional            | One of `development` \\| `production` \\| `test`. Gates the production-only refinements above.        |\n|                                                   | `LOG_LEVEL`                | optional            | Pino level. Default `info`.                                                                         |\n|                                                   | `OTEL_EXPORTER_OTLP_*`     | optional            | OpenTelemetry exporter config. Leave `OTEL_EXPORTER_OTLP_ENDPOINT` unset to disable tracing.        |\n| **web** ([`.env.example`](apps/web/.env.example)) | `VITE_API_URL`             | **`vite build`**    | Valid URL. Inlined at build time and embedded in CSP. `vite dev` allows it unset (uses Vite proxy). |\n|                                                   | `API_PORT` / `CLIENT_PORT` | optional (dev only) | Valid port (1–65535). Defaults `8787` / `8788`.                                                     |\n| **mcp** ([`.env.example`](apps/mcp/.env.example)) | `PAGENT_URL`               | optional            | Valid URL when set. Default `https://pagent.up.railway.app`.                                        |\n\nWhen validation fails, the process logs the offending field and exits with a non-zero code — CI catches misconfigured deploys (`build:web` runs in CI with a placeholder `VITE_API_URL`) before they ship.\n\n## Install as a Claude Code plugin\n\n**Prerequisites:** Claude Code, Node 22+.\n\n**1. Install** — paste these two commands into any Claude Code session:\n\n```\n/plugin marketplace add blockful/pagent\n/plugin install pagent@pagent\n```\n\nThe MCP server ships pre-bundled (`apps/mcp/server.bundle.js`), so there's no `npm install` step on your side — Claude Code can spawn it directly.\n\n**2. Verify** — confirm the MCP server is connected:\n\n```\n/mcp\n```\n\nYou should see `pagent` listed with `show_ui` and `check_result` tools. The plugin also ships a skill (`pagent`) that teaches the polling pattern.\n\n**3. Use it** — try this prompt:\n\n\u003e \"Use the pagent skill to ask me my favorite color via a UI form.\"\n\nThe agent calls `show_ui`, prints a URL (hosted at `https://pagent.vercel.app`), you submit, and the conversation continues.\n\n**Point at a different service?** Set `PAGENT_URL` before launching Claude. By default the MCP talks to `https://pagent.up.railway.app`.\n\n## Use it from any MCP client (HTTP transport)\n\nBeyond the Claude Code plugin, pagent's MCP also speaks the streamable HTTP transport — so any MCP-capable client (Codex, OpenCode, Cursor, Cline, Continue, etc.) can connect with one command and zero local install:\n\n```bash\n# Claude Code, without the plugin (HTTP MCP, scoped to the current project)\nclaude mcp add --scope project --transport http pagent \"https://pagent.up.railway.app/mcp\"\n```\n\nFor other clients, drop this into whichever `mcp.json` / config file they read:\n\n```json\n{\n  \"mcpServers\": {\n    \"pagent\": {\n      \"type\": \"http\",\n      \"url\": \"https://pagent.up.railway.app/mcp\"\n    }\n  }\n}\n```\n\nThe HTTP MCP runs in the same process as the REST service (single Railway deploy, no extra infra) and shares its tool definitions with the bundled stdio MCP — `show_ui` / `check_result` behave identically across transports, including the polling-pattern guidance baked into the tool descriptions.\n\n**Self-hosting?** Replace the URL with `http://your-host:8787/mcp`.\n\n## Quick start (development)\n\n```bash\ngit clone git@github.com:blockful/pagent.git\ncd pagent\nnpm install                         # workspaces install for all three apps\nnpm run dev                         # API on :8787, renderer on :8788\nnpm run build:mcp                   # rebuild apps/mcp/server.bundle.js after editing server.ts\n```\n\nOpen `http://localhost:8788/\u003cpage_id\u003e` to view a page. To use the local API from a Claude session, install the plugin from the local checkout instead of the marketplace:\n\n```bash\nclaude --plugin-dir /absolute/path/to/pagent\nPAGENT_URL=http://localhost:8787 claude   # then talk to local API\n```\n\n### Shutdown behavior\n\nThe API handles `SIGTERM` and `SIGINT` gracefully:\n\n1. Stops accepting new connections.\n2. Waits up to 10 seconds for in-flight requests to finish.\n3. Force-closes idle keep-alives if the timeout is hit.\n4. Closes the Postgres pool and exits.\n\nRailway sends SIGTERM during deploys; Ctrl+C in dev sends SIGINT.\n\n### Quality gate\n\nA Husky `pre-push` hook runs `typecheck → lint → format:check → test`\non every `git push`. To run it manually before pushing:\n\n    .husky/pre-push\n\nTo bypass in an emergency: `git push --no-verify` (don't make this a habit).\n\n## Deploy\n\n### `apps/api/` → Railway\n\n`apps/api/railway.json` contains the build + start config. To deploy:\n\n1. Create a new Railway service from this repo.\n2. Set **Root Directory** to `apps/api` so Railway picks up the railway.json.\n3. Set environment variables (see `apps/api/.env.example`):\n   - `PUBLIC_URL` — the Vercel URL of `apps/web` (e.g. `https://pagent.vercel.app`). Used in `show_ui` responses. **Required in production.** Boot fails loudly if missing.\n   - `ALLOWED_ORIGINS` — comma-separated origins allowed to call the API (set to your Vercel URL). **Required in production.** API boot fails loudly if missing.\n   - `PORT` — Railway sets this automatically; the server reads it.\n   - `PAGE_TTL_MS` — optional; default 30 minutes.\n   - `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MS` — optional. Per-IP rate limit on `POST /new`. Defaults: 30 / 60000 (30 req/min). Tune up for load tests.\n   - `OTEL_EXPORTER_OTLP_ENDPOINT` — optional. Grafana Cloud OTLP HTTP base URL (e.g. `https://otlp-gateway-prod-us-central-0.grafana.net/otlp`). Leave unset to disable observability entirely. See `apps/api/.env.example` for the rest of the OTel envs.\n4. Deploy. Railway runs `npm install` (which walks up to the workspace root) and starts the API with `npm -w @pagent/api run start`.\n\nThe `/health` endpoint is configured as the healthcheck path. Returns 200 only when the DB is reachable; 503 otherwise.\n\n### `apps/web/` → Vercel\n\n`apps/web/vercel.json` handles the build. To deploy:\n\n1. Create a new Vercel project from this repo.\n2. Set **Root Directory** to `apps/web` so vercel.json is picked up.\n3. Set environment variables (see `apps/web/.env.example`):\n   - `VITE_API_URL` — the Railway URL of `apps/api` (e.g. `https://pagent.up.railway.app`). Inlined at build time, so a redeploy is needed if this changes. **Required for `vite build`** — the build fails loudly if missing or malformed (prevents shipping a bundle that silently calls relative paths).\n4. Deploy. Vercel runs `npm install` from the monorepo root (workspace install) and `npm run build:web`, outputting `apps/web/dist/`.\n\n`vite dev` (i.e. `npm run dev`) does not require `VITE_API_URL` — it falls back to Vite's proxy for same-origin paths, so local development works zero-config.\n\n#### Security headers\n\nThe renderer derives its strict Content-Security-Policy from\n`VITE_API_URL` at build time and injects it as a `\u003cmeta\u003e` tag in\n`index.html`. Self-hosters at a different API origin only need to\nset `VITE_API_URL` correctly when deploying to Vercel — no source\nedit required. HSTS / nosniff / frame-deny / referrer-policy /\npermissions-policy are set as HTTP headers via `apps/web/vercel.json`.\n\n### Order matters\n\nDeploy Railway first to get the API URL. Then deploy Vercel with `VITE_API_URL` set to it. Then go back to Railway and set `PUBLIC_URL` + `ALLOWED_ORIGINS` to the Vercel URL.\n\n## Operations\n\nThis section is for on-call engineers. It documents the observable surface of the\nsystem so you can answer \"is it broken, what broke, how do I fix it?\" without\nreading the code.\n\n- **Machine-readable API contract:** `GET /openapi.json` (canonical JSON, OpenAPI 3.1) or `GET /openapi.yaml` (same spec, raw YAML for humans). Interactive reference at `GET /docs` (Scalar). Source lives at `docs/openapi.yaml` in the repo and is loaded once at boot.\n\n### Health check\n\n```\nGET /health\n```\n\nThis is an ops endpoint, not part of the API contract. Railway polls it\nautomatically and restarts the service on 503.\n\n**Healthy response** (`200`):\n\n```json\n{ \"ok\": true, \"db\": \"ok\" }\n```\n\n**Failure response** (`503`) — Postgres is unreachable:\n\n```json\n{ \"ok\": false, \"db\": \"error\" }\n```\n\nThere is no `pages` count in the response; the field was removed in an earlier\nrefactor. The response shape is exactly what is shown above.\n\nQuick smoke from the terminal:\n\n```bash\ncurl -sf https://pagent.up.railway.app/health\n```\n\n### Logs and traces\n\n**Logs:** The API uses [Pino](https://getpino.io). In production (`NODE_ENV=production`)\nevery event is one JSON object per line. In dev the output is pretty-printed via\n`pino-pretty`. Each line carries at minimum `level`, `time`, and `msg`, plus\nper-event fields. Request log lines look like:\n\n```\n{\"level\":30,\"time\":1709120000000,\"req_id\":\"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6\",\"method\":\"POST\",\"path\":\"/new\",\"status\":201,\"duration_ms\":17,\"msg\":\"request\"}\n```\n\nEvery response carries `X-Request-ID` (32-char hex). Quote it in bug\nreports — the same ID appears on every log line for that request and\nany traces in Grafana.\n\nWhere to find them: **Railway dashboard → API service → Logs tab**.\n\n**Traces:** If `OTEL_EXPORTER_OTLP_ENDPOINT` is set, traces flow to Grafana Cloud\n(Tempo) over OTLP/HTTP. Auto-instrumentation covers HTTP, fetch, and Postgres.\nThe Pino instrumentation injects `trace_id` and `span_id` into every log line,\nso pivoting from a log line to its trace in Grafana is a single click. Leave\n`OTEL_EXPORTER_OTLP_ENDPOINT` unset to disable OTel entirely — the SDK does not\nstart and there is zero overhead.\n\n**No metrics endpoint:** there is no `/metrics` or Prometheus scrape target.\nOperations rely on log aggregation and traces. Build throughput/latency dashboards\nin Grafana from the trace and log streams.\n\n### Common failure modes\n\n| Symptom                                                   | Likely cause                                             | Where to look                                    | First response                                                                                                                           |\n| --------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |\n| `GET /health` → 503                                       | Postgres unreachable                                     | Supabase status page; Railway DB env vars        | Check Supabase dashboard. If the DB is up but the env var was rotated, restore `DATABASE_URL` in Railway and redeploy.                   |\n| Spike of 429s on `POST /new`                              | Per-IP rate limit hit (default 30 req / 60 s)            | Railway logs — group by client IP                | Legit spike: bump `RATE_LIMIT_MAX` in Railway env and restart (no redeploy needed). Abuse: block at the network edge.                    |\n| 413 on `POST /new`                                        | Request body \u003e 256 KB                                    | Log field `error: payload_too_large`             | If a real use case, raise `MAX_BODY_BYTES` in `apps/api/app.ts` (code change + redeploy). Otherwise it's spam; ignore.                   |\n| CORS errors in the browser console at `pagent.vercel.app` | `ALLOWED_ORIGINS` does not include the renderer's origin | Browser DevTools → Network → failing preflight   | Add the missing origin to `ALLOWED_ORIGINS` in Railway env and restart the service.                                                      |\n| Boot failure with `ZodError` in Railway logs              | A required env var is missing                            | Railway logs (the process exits before it binds) | Read the Zod validation error — it names the missing field. Usually `PUBLIC_URL` or `ALLOWED_ORIGINS`. Set it in Railway, then redeploy. |\n\n### Rollback\n\nThe MCP plugin marketplace tracks `main`. Rolling back means reverting the bad\ncommit on `main`; Railway and Vercel auto-redeploy on push.\n\n```bash\ngit checkout main \u0026\u0026 git pull\ngit revert \u003cbad-sha\u003e          # creates a revert commit, safe for shared history\ngit push\n```\n\nIf the branch is not yet shared and a hard reset is acceptable:\n\n```bash\ngit reset --hard \u003cprev-good-sha\u003e\ngit push --force-with-lease\n```\n\nWatch CI go green, then verify:\n\n```bash\ncurl -sf https://pagent.up.railway.app/health \u0026\u0026 echo ok\n```\n\n### Operational tunables\n\nThese env vars can be changed in Railway (or locally in `apps/api/.env`) to tune\nbehaviour without touching code. `apps/api/.env.example` is the source of truth.\n\n| Var                           | Default                   | Effect                                                                                                    |\n| ----------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------- |\n| `PORT`                        | `8787`                    | Port the server listens on. Railway overrides this automatically.                                         |\n| `PUBLIC_URL`                  | _(required in prod)_      | Base URL of the renderer, returned in `show_ui` responses. Redeploy required after change.                |\n| `PAGE_TTL_MS`                 | `1800000` (30 min)        | How long a page lives before expiring. Raising it keeps pages alive longer but grows the DB.              |\n| `ALLOWED_ORIGINS`             | _(required in prod)_      | Comma-separated origins the CORS middleware allows. Add an origin here and restart — no redeploy.         |\n| `RATE_LIMIT_MAX`              | `30`                      | Maximum requests per window per client IP on `POST /new`. Raise for load tests; restart picks it up.      |\n| `RATE_LIMIT_WINDOW_MS`        | `60000` (60 s)            | The rolling window for the rate limit above.                                                              |\n| `OTEL_EXPORTER_OTLP_ENDPOINT` | _(unset = OTel disabled)_ | Grafana Cloud OTLP HTTP base URL. Set to enable traces; unset to disable. Restart required.               |\n| `LOG_LEVEL`                   | `info`                    | Pino log level: `fatal \\| error \\| warn \\| info \\| debug \\| trace`. Lower = more noise. Restart required. |\n\n### What we don't have yet\n\nGaps to keep expectations calibrated:\n\n- **No metrics endpoint.** Build latency/throughput dashboards from the trace and log streams in Grafana.\n- **No staging environment.** Every merge to `main` ships directly to the production Railway and Vercel services.\n- **No automated rollback.** The procedure above is manual. Wire a Grafana alert to trigger a revert workflow if you want automation.\n- **No alerting beyond Railway's healthcheck restart loop.** Add your own: Grafana alert on `/health` 503 rate, log error rate, or p95 latency when ready.\n- **No CHANGELOG.** Release notes live in GitHub Releases.\n\n## API\n\n```\nPOST   /new                  body: { spec }     -\u003e { id, url, expires_at }\nGET    /:id                                     -\u003e { spec, state, result, expires_at }\nPOST   /:id/result           body: \u003caction\u003e     -\u003e { ok }              (browser submits)\nGET    /:id/result                              -\u003e { state, result }   (agent reads, marks \"received\" on first read)\n```\n\nThe API publishes its OpenAPI 3.1 spec at the conventional locations:\n\n- `GET /openapi.json` — canonical machine-readable spec (what every third-party tool expects)\n- `GET /openapi.yaml` — same spec in YAML for humans\n- `GET /docs` — interactive Scalar API Reference\n\nThe hand-authored source lives at `docs/openapi.yaml` and is loaded once at boot.\n\nThe `spec` body is opaque to the service. V0 assumes A2UI v0.9 — there is no `format` tag on the wire.\n\nA page is single-shot and walks a 3-state machine: `open -\u003e submitted -\u003e received`. `POST /:id/result` requires `state === \"open\"` (otherwise 409). The first `GET /:id/result` after submit returns `state: \"submitted\"` and flips the page to `received`; subsequent reads return `state: \"received\"`. The renderer can detect that transition via `GET /:id` to upgrade its \"waiting for the agent\" banner.\n\n## Smoke test\n\nWith `npm run dev` running, in another terminal:\n\n```bash\nnpm run smoke\n# (alias for `node apps/mcp/smoke.mjs`)\n# follow the printed URL, fill the form — check_result returns the action\n```\n\nOr with curl, end-to-end:\n\n```bash\n# 1. Create a page with a spec.\ncurl -s -X POST http://localhost:8787/new \\\n  -H 'content-type: application/json' \\\n  -d '{\"spec\":[{\"createSurface\":{\"surfaceId\":\"main\",\"catalogId\":\"https://a2ui.org/specification/v0_9/basic_catalog.json\"}},{\"updateComponents\":{\"surfaceId\":\"main\",\"components\":[{\"id\":\"root\",\"component\":\"Column\",\"children\":[\"title\",\"field\",\"submit\"]},{\"id\":\"title\",\"component\":\"Text\",\"text\":\"Color?\"},{\"id\":\"field\",\"component\":\"TextField\",\"label\":\"Color\",\"value\":{\"path\":\"/color\"}},{\"id\":\"submit-label\",\"component\":\"Text\",\"text\":\"Send\"},{\"id\":\"submit\",\"component\":\"Button\",\"child\":\"submit-label\",\"variant\":\"primary\",\"action\":{\"event\":{\"name\":\"submitted\",\"context\":{\"color\":{\"path\":\"/color\"}}}}}]}}]}'\n# -\u003e { \"id\": \"\u003cpageId\u003e\", \"url\": \"http://localhost:8788/\u003cpageId\u003e\", \"expires_at\": ... }\n\n# 2. Open the URL in a browser and click Send. Then poll:\ncurl -s http://localhost:8787/\u003cpageId\u003e/result\n# -\u003e { \"state\": \"open\",      \"result\": null }       (before submit)\n# -\u003e { \"state\": \"submitted\", \"result\": { ... } }    (first read after submit; flips to received)\n# -\u003e { \"state\": \"received\",  \"result\": { ... } }    (subsequent reads)\n```\n\n## Releases\n\nFor the release procedure, see [docs/RELEASING.md](docs/RELEASING.md).\n\nFor security-related reports, see [SECURITY.md](SECURITY.md).\n\nFor contribution guidelines, see [CONTRIBUTING.md](CONTRIBUTING.md).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fblockful%2Fagent-ui-session","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fblockful%2Fagent-ui-session","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fblockful%2Fagent-ui-session/lists"}