| name | noui-generalize |
| description | Use this skill when the user wants to generalize a recorded MCP workflow, rename tools to readable names, replace raw API params with natural-language parameters, fix bot detection issues (Akamai, Cloudflare, PerimeterX), rewrite operations to use CDP browser execution, or make a generated MCP server usable by Claude Code. Triggers on "generalize a recorded workflow", "rename MCP tools", "replace raw API params with readable names", "make the MCP usable by Claude Code", "generalize tool signatures", "I can't use the MCP tools", "Akamai is blocking", "429 with valid cookies", "TLS fingerprinting", "CDP fetch", "execute from inside the browser", "anti-bot workaround", "tabby credentials are empty", or "browser is authenticated but API calls fail". |
| metadata | {"author":"adoptai"} |
NoUI Generalize
Take a generated FastMCP server (or Skill) and make it work and usable:
- Execution strategy — generated servers execute inside the Tabby browser via CDP by default (see
/noui-record-workflow → How Execution Works). This skill handles the edge cases: SPA content that needs navigation + DOM scraping, sites requiring HITL login, profiles that need credential-type fixes, or rare cases where the --execution-mode http fallback is the right call.
- Interface cleanup — replace raw internal API parameters (
f_sid, bl, reqid) with natural-language names (origin, destination, departure_date) so Claude Code can invoke tools without domain knowledge.
Prerequisite: /noui-record-workflow must be complete and the MCP server must exist under workbench/mcp_servers/. For authenticated sites, /noui-record-login must also be complete with a running Tabby session.
Works for both MCP and Skill outputs
The generalization logic is identical for either output format:
- MCP output: edit
workbench/mcp_servers/<app>/<server>/operations/<tool>.py — the body of async def execute(...). The FastMCP tool signature in server.py mirrors the execute() signature, so renaming params also requires updating the server.py decorator args.
- Skill output: edit
workbench/skills/<app>/operations/<tool>.py — the body of async def execute(...) and the _build_parser() argparse registrations, because the skill operation is a standalone CLI script. The SKILL.md body's command examples also reference the flag names, so update them too (or regenerate SKILL.md by re-running workflow export --as skill --description-override "...").
The Phase 0 execution diagnosis (bot detection, empty credentials, profile promotion) applies to both outputs unchanged — both runtimes share the same noui_runtime/auth.py and the same Tabby credential flow.
After generalizing a skill, use /noui-generate-skill (not /noui-generate-mcp) for install / test.
Critical Rules (Never Violate)
- NEVER rewrite all tools at once — propose the new name and parameter list for each tool and get user approval before editing any files
- ALWAYS preserve existing execution mechanics (URL, method, headers, CDP vs. httpx) unless deliberately switching modes — only the Python function interface changes
- ALWAYS hardcode values that were static in the recording (session routing params, build labels,
bl, f_sid, reqid, soc_app, etc.) — do not expose infrastructure params to the caller
- NEVER ask questions that can be answered by reading the code or URL
httpx only appears in generated operations when --execution-mode http was used explicitly. Default generated operations use noui_runtime.cdp.cdp_fetch. If you see httpx in a default-mode server, something is wrong.
- ALWAYS connect to the direct CDP port (
localhost:9222) for Runtime.evaluate — the Tabby relay (localhost:9223) only allows screencast and input events.
- ALWAYS use
credentials: 'include' in browser-side fetch() calls — the generated cdp_fetch helper already does this; preserve it when hand-editing.
- NEVER assume "Login successful" in worker logs means login actually worked — CloakBrowser reports success when the DSL finishes, not when auth cookies appear. Always verify by checking cookies.
- After rewriting, always remind the user to restart Claude Code to reload the updated tools
Phase 0 — Diagnose Execution Strategy
Before touching tool names or params, check whether the tools actually work.
0a. Find and test the server
ls -lt workbench/mcp_servers/
Run a quick end-to-end test:
.venv/bin/python -c "
import asyncio, json, sys
sys.path.insert(0, 'workbench/mcp_servers/<app_slug>/<server_id>')
from operations.<tool_name> import execute
async def test():
result = await execute(...) # fill in sample params
print(json.dumps(result, indent=2)[:2000])
asyncio.run(test())
"
0b. Classify the result
| Result | Diagnosis | Next step |
|---|
| 200 with real data | Tool works — skip to Phase 2 (interface cleanup) | |
No Tabby page matching '<domain>' | No tab open on the target site | Open the site in the Tabby browser, or tabby session ensure --profile <id> |
| Connection refused on 9222 | Tabby session not running | tabby session ensure --profile <id> |
CORS error / credentials include not allowed | Cross-origin fetch blocked | Re-export with --execution-mode http |
| 429 / "Too Many Requests" from CDP | Very rare — Akamai flagging in-browser too | Phase 1 — verify the account, consider HITL re-login |
Server was generated with --execution-mode http and returns 429 | httpx blocked by TLS fingerprinting | Re-export without --execution-mode http to land on the CDP default |
Empty credentials / name: "" (http mode only) | Tabby credential_types format bug | Phase 1, Fix A |
No active profile found (http mode only) | Profile still STAGING | Phase 1, Fix B |
0c. Confirm bot detection (if 429)
Verify the browser itself can make the call:
import asyncio, json, websockets, httpx
async def test():
resp = await httpx.AsyncClient().get("http://localhost:9222/json", timeout=5)
targets = resp.json()
ws_url = [t["webSocketDebuggerUrl"] for t in targets if t["type"] == "page"][0]
js = """
fetch('/api/endpoint/test', { credentials: 'include' })
.then(r => r.text().then(t => JSON.stringify({status: r.status, body: t.substring(0, 200)})))
"""
async with websockets.connect(ws_url) as ws:
await ws.send(json.dumps({
"id": 1, "method": "Runtime.evaluate",
"params": {"expression": js, "awaitPromise": True, "returnByValue": True}
}))
resp = json.loads(await ws.recv())
print(resp["result"]["result"]["value"])
asyncio.run(test())
If 200 from browser but 429 from httpx → confirmed TLS fingerprinting. Proceed to Phase 1.
Phase 1 — Fix Execution Strategy
Fix A — credential_types format bug
After a fresh login register, the service_profiles table stores credential_types.cookies as a string array (["cookie_a", "cookie_b"]), but credentials.service.ts expects object arrays with .name. Every cookie comes back with empty name and value.
SELECT credential_types FROM service_profiles WHERE profile_id = '<profile>';
UPDATE service_profiles SET credential_types = '{
"cookies": [
{"name": "EG_SESSIONTOKEN", "volatility": "STABLE"},
{"name": "bm_sz", "volatility": "VOLATILE"},
{"name": "ak_bmsc", "volatility": "VOLATILE"},
{"name": "user", "volatility": "STABLE"}
],
"headers": []
}'
WHERE profile_id = '<profile>';
Mark cookies that rotate frequently (Akamai tokens: bm_sz, ak_bmsc, bm_so, bm_s, _abck) as VOLATILE. Everything else is STABLE.
Fix B — Promote profile to ACTIVE
The credentials API (POST /credentials/request) only resolves ACTIVE profiles. Fresh registrations start as STAGING.
UPDATE service_profiles SET version_state = 'ACTIVE' WHERE profile_id = '<profile>';
Fix C — Set refresh_interval_seconds
The keepalive runner defaults to 3600s between artifact re-exports. Set lower for faster credential refresh:
UPDATE applications
SET export_policy = export_policy || '{"refresh_interval_seconds": 60}'::jsonb
WHERE name ILIKE '%<app_name>%';
Fix D — HITL login (when CloakBrowser fails)
If the automated login DSL can't complete (Akamai blocks form interactions), log in manually:
- Open Chrome →
chrome://inspect
- Click "Configure..." → add
localhost:9222
- Click inspect on the page target
- In DevTools Console:
window.location = 'https://example.com/login'
- Complete login manually via the screencast panel
- Force re-export:
UPDATE sessions SET artifacts_last_exported_at = NULL WHERE id = '<session_id>';
Port 9222 vs 9223: Port 9222 is the direct CDP endpoint (full access). Port 9223 is Tabby's relay that only allows Page.screencast* and Input.dispatch*Event. Use 9222 for all workaround steps.
Fix E — CDP execution is the default (no rewrite needed)
CDP-based execution is now the default generated output. A recently exported
server already has operations that call find_page and cdp_fetch from
noui_runtime/cdp.py. If you are hand-editing or need to reason about the
pattern, the primitives are:
from noui_runtime.cdp import find_page, cdp_fetch
ws_url = await find_page("api.example.com")
result = await cdp_fetch(
ws_url,
"https://api.example.com/v1/items",
method="POST",
headers={"Accept": "application/json"},
body={"name": "x"},
)
cdp_fetch always sets credentials: 'include'. The full specification lives
in compiler/runtime/cdp_adapter.py.
When you still need to re-generate or hand-edit:
- The server was generated with
--execution-mode http and keeps getting 429.
→ Re-export without the flag to land on the CDP default.
- You need SPA content that isn't exposed as a JSON API. → see DOM-scrape
generalization below.
- The API truly cannot be reached from the browser origin (CORS, or
server-to-server endpoint). → keep
--execution-mode http and fix the
underlying auth / credential issue instead.
For the rationale (TLS fingerprinting, why 9222, why credentials: 'include'),
see /noui-record-workflow → How Execution Works.
DOM-scrape generalization (SPA-rendered content)
When data is rendered by client-side JS and not available as a JSON API, the
generated cdp_fetch path won't work — you need to navigate and scrape the
rendered DOM. This is a hand-written generalization on top of the CDP default:
import asyncio, json
import websockets
async def _navigate_and_scrape(ws_url: str, url: str, extract_js: str, wait_seconds: int = 7) -> dict:
"""Navigate to a URL, wait for SPA render, then extract data via JS."""
async with websockets.connect(ws_url) as ws:
await ws.send(json.dumps({
"id": 1, "method": "Page.navigate", "params": {"url": url}
}))
await ws.recv()
await asyncio.sleep(wait_seconds)
from noui_runtime.cdp import cdp_eval
return await cdp_eval(ws_url, extract_js)
To find the right DOM selectors, inspect data-stid or similar attributes:
js = """
JSON.stringify([...new Set(
[...document.querySelectorAll('[data-stid]')]
.map(e => e.getAttribute('data-stid'))
)].sort())
"""
What to change when adding DOM-scrape to an operation:
- Keep
from noui_runtime.cdp import find_page, cdp_eval (the default module already exposes both).
- Add
import asyncio, json, websockets for Page.navigate.
- Replace the
cdp_fetch(...) call with _navigate_and_scrape(ws_url, target_url, extract_js).
- Extract the shape you need via a JS expression that returns
JSON.stringify(...).
Single browser instance caveat: Tabby runs one CloakBrowser per session. CDP navigation changes the page — if keepalive actions need the homepage, coordinate or set keepalive to dom_check on body only.
Phase 2 — Understand the Server
-
Read tools.json — note all tool names and their raw parameter names.
-
Read each operations/<name>.py — understand the call: URL, method, request body structure, which params are infrastructure vs. business inputs.
-
Ask the user:
"What workflow did you record? Describe in plain language what you were doing — for example: 'I searched for flights from Fortaleza to Seattle on June 1 for 1 adult'."
Use the answer to anchor the business meaning of each tool.
Phase 3 — Close Gaps Per Tool
For each tool, ask only the questions needed to understand which parameters carry business input:
- "Which parameter in this tool controls [the thing you described]? Do you know what value you used during recording?"
- "This tool posts to
[endpoint] — is this the main [action]? What inputs does it need from the user?"
- For opaque bodies: "The request body appears to be binary-encoded. Based on what you described (origin, destination, date), which of these raw params do you think carries each value?"
Skip questions that are answerable from the URL path, parameter names, or values visible in the code.
Phase 4 — Rewrite Tools One at a Time
For each tool, follow this sequence:
4a. Propose
Show the user the proposed new interface before touching any files:
Tool: create_data_batchexecute
New name: search_flights
New parameters:
- origin: str # IATA code or city name (e.g. "FOR", "Fortaleza")
- destination: str # IATA code or city name (e.g. "SEA", "Seattle")
- departure_date: str # Date in YYYY-MM-DD format
- passengers: int = 1 # Number of passengers
Hardcoded (from recording):
- f_sid: "<value from recording>"
- bl: "<value>"
- reqid: <value>
- soc_app: <value>
Execution: CDP fetch / httpx (state which)
Approve this? (yes / adjust: ...)
4b. Edit on approval
Once approved, make four edits:
-
operations/<old_name>.py — rewrite the function signature and body:
- New function name matches the approved tool name
- New parameters are natural-language (
origin, destination, etc.)
- Infrastructure params are hardcoded as local variables
- Build the raw request from the natural params (string formatting, encoding)
- Use CDP fetch if Phase 1 flagged bot detection; otherwise preserve the existing HTTP call
-
tools.json — update the entry:
name → new tool name
description → plain-English description of what it does
parameters → updated list with natural-language names, types, descriptions
-
server.py — update the import and tool registration to use the new function name (if the function was renamed)
-
API.md — refresh the documentation:
.venv/bin/python cli/main.py mcp docs <server_id>
This overwrites API.md from the current tools.json. Run it after every tool edit, not just at the end.
4c. Move to next tool
Repeat Phase 4 for each tool. Do not batch edits.
Phase 5 — Iterate After Testing
After all tools are rewritten, do a final docs refresh:
.venv/bin/python cli/main.py mcp docs <server_id>
Then tell the user:
"Done. API.md is up to date. Please restart Claude Code (close and reopen, or run /reconnect) to reload the updated tools. Then try invoking the workflow — for example: 'search for flights from Fortaleza to Seattle on June 1'."
When the user reports results, fix any issues:
| Problem | Fix |
|---|
| Wrong parameter mapping | Re-read the operation, ask clarifying question, re-propose |
| Missing required parameter | Add it to signature and body |
| Tool call fails with HTTP error | Read the error body, compare to original recording values |
| Body encoding wrong | Check if original body was URL-encoded, JSON, or protobuf — reconstruct accordingly |
| 429 from tool call on a CDP-default server | Verify Tabby has an authenticated tab open; if yes, the account may be flagged — redo HITL login (Phase 1, Fix D) |
429 from tool call on --execution-mode http server | Re-export without the flag to use the CDP default |
| CDP connection refused | Tabby session not running — tabby session ensure --profile <id> |
fetch() returns 403 inside browser | Session expired — redo HITL login (Phase 1, Fix D) |
Keep iterating until the user can successfully invoke the workflow using natural language.
What to Hardcode vs. Expose as Parameters
| Hardcode | Expose as parameter |
|---|
f_sid, bl, reqid, soc_app | Origin, destination, dates |
| Build labels, session routing tokens | Search inputs (keywords, quantities) |
| CSRF tokens captured during recording | Filters (cabin class, stops) |
| App version identifiers | User preferences the tool is supposed to accept |
Infrastructure headers (x-goog-ext-*) | Any value that changes meaningfully per call |
When in doubt: if the value was the same every time during recording and doesn't carry user intent, hardcode it.
Dynamic-Header-Injection Sites (JS-injected auth)
Some SPAs acquire a short-lived bearer token in-memory (fetch from /auth/init or similar on page load), then wrap window.fetch / XMLHttpRequest with an interceptor that sets Authorization: Bearer <jwt> right before every XHR. The cookie jar is empty of auth material, localStorage / sessionStorage don't hold the bearer either, yet every real API call carries it. Calling the API from cdp_fetch with credentials: 'include' alone will get you a 401 or 403.
Detect
- HAR contains an
Authorization: Bearer eyJ... header whose JWT jti (or sub, exp) rotates between recordings of the same flow.
document.cookie at runtime does not include the bearer substring.
localStorage and sessionStorage dumps don't contain the bearer either.
- The recorded API host is on a different subdomain from
www.* (e.g. api-prod-*, api.*).
Workaround — CDP Network-event sniffing
Enable CDP's Network domain, reload the page, listen for Network.requestWillBeSent, and grab the Authorization off the first request that matches your target host. Cache in /tmp with a TTL, invalidate on 401/403, retry once.
Reference implementation (10-line core):
await ws.send({"id": 1, "method": "Network.enable"})
await ws.send({"id": 2, "method": "Page.reload"})
deadline = loop.time() + 20
while loop.time() < deadline:
msg = json.loads(await ws.recv())
if msg.get("method") != "Network.requestWillBeSent":
continue
url = msg["params"]["request"].get("url", "")
if TARGET_HOST not in url:
continue
authz = {k.lower(): v for k, v in msg["params"]["request"].get("headers", {}).items()}.get("authorization", "")
if authz.startswith("eyJ"):
return authz
See .claude/skills/indigo-flight-search/noui_runtime/indigo_auth.py for the full cache + retry wrapper around this core. The IndiGo skill is the canonical example of this pattern in this repo.
Forward pointer: When the retro-C1 generic sniff_headers() helper lands in noui_runtime, delete the hand-written module and replace the import. See plans/noui/noui-agent-friction-retro-plan.md Section C1.
Per-host static client IDs
Sites of this shape often pair the dynamic JWT with a stable user_key / x-api-key / x-client-id header that is per-host but constant across sessions (look for the same value across every request to that host in the HAR). Hardcode those — they're infrastructure, not user input.
Known Anti-Bot Sites
| Site | Bot Detection | CloakBrowser Login | Workaround |
|---|
| Expedia | Akamai Bot Manager | Form blocked silently; reaches homepage but can't submit | HITL login + CDP fetch + DOM scrape |
| Generic SPA with JS-injected auth | Token not in cookies/localStorage; Authorization is set by a fetch interceptor in the page's JS bundle | n/a (site may be anonymous) | Sniff the Authorization via CDP Network.requestWillBeSent on page reload; cache per session. See Dynamic-Header-Injection Sites above. |
IndiGo (api-prod-*-skyplus6e.goindigo.in) | Anonymous session; per-host user_key + rotating JWT; Akamai cookies | n/a | Hardcode per-host user_keys; sniff JWT at runtime. Reference: indigo-flight-search skill. |
Architecture Notes
- Why cookies alone fail: Akamai fingerprints the TLS ClientHello, HTTP/2 settings frame, header order, and other transport-layer signals.
httpx/curl have distinctly different fingerprints from Chrome, even with identical cookies.
- Why CDP works:
Runtime.evaluate + fetch() executes inside the real Chrome process. The HTTP request goes through Chrome's network stack with its native TLS implementation and fingerprint.
- Port 9222 vs 9223: Port 9222 is the direct CDP endpoint (full access). Port 9223 is Tabby's relay proxy that only allows
Page.startScreencast, Page.stopScreencast, Page.screencastFrameAck, and Input.dispatch*Event. Use 9222 for this workaround.
- Single browser instance: Tabby runs one CloakBrowser per session. CDP navigation changes the page for that session — if keepalive needs the homepage, set keepalive health checks to
dom_check on body rather than URL-based checks.
Decision Flow
Start
│
Phase 0: Test the tool as-is
│
├─ Works (200 with data)?
│ └─ Skip to Phase 2 (interface cleanup)
│
├─ "No Tabby page matching <domain>"?
│ └─ Open the site in Tabby, or `tabby session ensure --profile <id>`
│
├─ CORS / cross-origin error?
│ └─ Re-export with `--execution-mode http`
│
├─ 429 on a default-mode server?
│ └─ Phase 1: Fix D (HITL re-login) — the session or account is flagged
│
├─ 429 on an `--execution-mode http` server?
│ └─ Re-export without the flag to land on the CDP default
│
├─ Empty credentials / name: "" (http mode)?
│ └─ Phase 1: Fix A (credential_types format)
│
├─ "No active profile" (http mode)?
│ └─ Phase 1: Fix B (promote to ACTIVE)
│
├─ Login didn't actually work?
│ └─ Phase 1: Fix D (HITL login)
│
├─ 401/403 on default CDP server, cookies appear correct?
│ └─ Check HAR: is `Authorization` present and does its JWT `jti` rotate across recordings?
│ └─ Yes → Dynamic-header-injection pattern; see *Dynamic-Header-Injection Sites* section
│
Phase 2: Read tools.json + operations + ask user about workflow
│
Phase 3: Close gaps per tool (targeted questions only)
│
Phase 4: Rewrite tools one at a time (propose → approve → edit)
│
Phase 5: Test, iterate, remind user to restart Claude Code
Related Skills
/noui-record-login — Record login and register with Tabby (run first for authenticated sites)
/noui-record-workflow — Record and export the workflow (run first to generate the server)
/noui-generate-mcp — Server lifecycle after generalization (start/stop/connect to Claude Code)
Source: adoptai/noui — distributed by TomeVault.