skills/descope-byos-builder/SKILL.md
Use when building React "Bring Your Own Screen" (BYOS) custom UI on top of a Descope flow — takes exported flow JSONs, extracts the real interaction IDs and outputs, generates BYOS components that match hosted parity, and avoids the rediscovery-the-hard-way failure modes (silent form rejection, shared screen-name collisions, anonymous-session stickiness, nested-form hydration errors, wrong form keys, dead-end buttons, missing OAuth provider field).
npx skillsauth add descope/skills descope-byos-builderInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Translate Descope flow JSON exports into working React BYOS screens that call state.next(interactionId, form). The failures below are recorded from real BYOS sessions — every one cost 15–60 minutes the first time.
add-passkeys) that run after logged-inDon't use for: flows that fully work with the hosted <Descope flowId=... /> widget (BYOS is a tradeoff — you give up flow edits propagating without code changes).
Ground every BYOS component in the exported flow JSON. Do not guess interaction IDs, output key names, or screen names. Every failure in the catalog starts with someone making up a value that looked reasonable.
This skill cannot fetch flows from the Descope console — the agent has no console access. Before doing any work, ask the user to provide:
arguments.flowId), including post-auth promotion subflows (e.g. add-passkeys). Accept either file paths or pasted JSON.AuthProvider / SDK init.If the user only provides the main flow JSON, stop and ask for subflows before generating components — missing subflow JSONs is the #1 cause of [byos] no handler for screen "..." runtime errors.
Collect flow JSONs from the user. Agent cannot reach the Descope console — user must export and paste/path them in. Need the main flow AND every subflow it invokes (LoadSubflow actions with arguments.flowId). This includes post-auth promotion subflows that run AFTER a logged-in action (e.g. add-passkeys) — easy to miss because the user is already authenticated by then. If only the main flow was provided, scan it for LoadSubflow actions and ask the user to export each one before proceeding. Missing subflow JSONs → undiscovered screens → [byos] no handler for screen … at runtime.
Parse with parse-flow.mjs (in this directory). Run node parse-flow.mjs <path/to/flow.json> — it prints every screen task with screenName, allInputKeys, contextKeys, next-rules (interactionId → taskId), UI node summaries (input name attrs, button labels), and subflow invocations.
Build the screen-name map. One React component per unique screen name across all flows. Watch for collisions — multiple tasks often share the name (e.g., "Welcome Screen" used for email-entry AND password-entry). When collisions exist, write a router component that dispatches based on state.context.form.* heuristics (see Gotchas → Screen name collisions).
Write each component. Every BYOS screen does the same four things:
state.context.sentTo.maskedEmail, state.context.form.email, etc.form (via setForm({ ...form, key: value }))state.next(interactionId, payload) on button clickstate.error?.text when Descope reports errorsWire the flow. The Descope React SDK provides <Descope flowId="..." onScreenUpdate={handler} /> from @descope/react-sdk as the BYOS entry point — onScreenUpdate receives (screenName, state, next) and you dispatch to the matching screen component. A common pattern is to build a thin FlowOrByos wrapper that takes a byosScreens map and dispatches internally, but FlowOrByos is not an SDK export — you build it. Mount one instance at the entry point. On onSuccess, invalidate any auth-state caches (see Gotchas → Session stickiness).
Verify end-to-end. Walk every user journey the flow supports. Any screen that hits [byos] no handler for screen "..." in the console is missing from your map.
Note: Several rules below (nested
<form>tag behavior, WebAuthn ceremony ownership,ctxKeyprefill,componentsConditionsfield name, E.164 silent rejection) are empirically derived from real BYOS sessions and are not explicitly documented in Descope's official docs — but have been validated in production and verified against flow JSON structure.
name prop, NOT allInputKeys or inputsMetadata.key. Task 40's Set Password input has name="newPassword" but allInputKeys: ["newPassword_noPolicyOverrides"]. Submit with { newPassword: "..." }.state.next payloads. Spreading the full accumulated form across subflow boundaries can pollute context and cause silent failures.<form> tags. Descope's web component wraps children in a <form>. Use <div> + onClick + onKeyDown={makeEnterHandler(submit)}.provider in the payload ({ provider: 'google' }), not only the interaction ID.onSuccess. Session upgrades (anonymous → verified) can reuse the same sub, so userId-keyed caches never auto-refresh.componentsConditions from screen JSON to mirror hide/show rules (e.g., hide "Sign in with code" when unauthUser.verifiedPhone is false).webauthn-update-user-start/finish, Sign Up or In / Passkeys), just fire state.next(interactionId). Never call navigator.credentials.create/get from BYOS. Never import @simplewebauthn/browser, startAuthentication, startRegistration, or any WebAuthn helper library — the Descope SDK already does both ceremony halves. SDK observes the action on onScreenUpdate and runs the ceremony itself; cancel/error surfaces as state.error.text.ctxKey for prefill. When an input node has props.ctxKey="someKey", seed form[name] from state.context[someKey] on first render via useEffect keyed on the context value (only set if local field is empty).Device Not Supported branches. WebAuthn flows commonly branch on deviceInfo.webAuthnSupport. The unsupported branch is a real screen task — give it a BYOS component, otherwise hosted widget renders mid-promotion.When two tasks share a screen name, you cannot guess a disambiguation signal. You must trace each path and build a "form field accumulation" table.
The process (non-negotiable — skipping this produces silent bugs):
next.rules until you hit the parent flow's entry point.allInputKeys — those are the form fields the user writes into on that path.Boolean(state?.context?.form?.<that field>) as the heuristic.Common mistake: picking a field that's populated on both paths because you didn't trace both paths fully.
Examples below are from one specific flow set — your task IDs, screen names, and subflow names will differ. They illustrate the method; always run
parse-flow.mjson your own flows.Example: for "Verify OTP" shared between
sign-in-sms-otpandprogressive-profile-sms,form.phonelooks attractive — but BOTH flows collect phone in a Phone-input screen before reaching Verify OTP. Pickform.passwordinstead: the sign-in-sms-otp path is entered from the parent flow's password screen which writesform.password; the progressive-profile-sms path is entered post-magic-link where no password was ever typed.
Reference heuristics (example from the flow set this skill was born from — not universal):
| Collision | Path A collects before this screen | Path B collects before this screen | Signal |
|-----------|------------------------------------|------------------------------------|--------|
| "Welcome Screen" (email-entry vs password-entry) | — | email | state.context.form.email |
| "Verify OTP" (sign-in-sms-otp vs progressive-profile-sms) | email, password, phone | email, phone | state.context.form.password (NOT phone — both collect it!) |
Three-way collision example (Magic Link Sent across main and two subflow variants):
| Path | Collects before this screen | |
|------|----------------------------|---|
| Main flow | email | no password |
| Subflow new-user | email, password, fullName | password + fullName |
| Subflow existing-user | email, password | password, no fullName |
Predicate: supportsChooseOther = !hasPassword || hasFullName.
Document the chosen heuristic at the top of the router component with the full trace. If the Descope console could rename one screen, ask — unique names beat heuristics forever.
Real success case: Two User Information tasks (verify-email-magic-link existing-user vs new-user branches) were renamed in console to User Information - Unverified - Email Only and User Information - Unverified - Email and Name. Heuristic was possible (presence of fullName output); rename was cheaper, clearer, and survives flow edits. Default toward rename.
Full failure catalog (problem → symptom → fix): see references/gotchas.md. Before blaming BYOS code for a silent failure, scan that file — most "doesn't work" symptoms match a known gotcha.
End-to-end test each user journey the flow supports:
task N and ends at task M with action=logged-in?onSuccess fires and the app reaches the expected authenticated state (not stuck anonymous)?onError fires and surfaces the Descope error message (not silent)?Device Not Supported branch?state.next(interaction) only — no navigator.credentials.* calls in BYOS code?Console should show no [byos] no handler for screen "..." warnings during a full journey walk.
parse-flow.mjs (this dir) — Node script. Input: flow JSON path(s). Output: a summary table per flow showing screen tasks, their inputs, their exit interactions, their UI node names, and subflow loaders. Run it, paste the output into your screen-map comment header, and you have the source of truth for every constant the BYOS components need.
If you find yourself:
{ ...form } into state.next across a subflow boundary — send only the outputs<form> tag inside your BYOS component — use <div>navigate() in onSuccess without invalidating auth caches — call invalidate firstnavigator.credentials.create/get from a BYOS click handler — the SDK does it; just fire the interaction@simplewebauthn/browser or any WebAuthn helper — same trap, third-party-lib variant; SDK does itAll of these mean: pause, re-read references/gotchas.md, verify against the flow JSON.
references/byos-component-patterns.md — positive code patterns: core wiring (onScreenUpdate → ByosState), screen router, screen skeleton, and complete examples for email, OTP, phone, OAuth, collision router, and ctxKey prefill. Start here when bootstrapping.references/gotchas.md — failure catalog: 19 real BYOS failure modes, each with symptom → root cause → fix. Also contains a pre-ship checklist.parse-flow.mjs — Node parser: node parse-flow.mjs <flow.json> [more.json ...]. Prints screen tasks, real form-key name props, interaction IDs, subflow loaders, and collision warnings. Run before writing any component.development
Use this skill whenever anyone asks about migrating from WorkOS to Descope — whether they're a developer doing it themselves or a technical lead evaluating the move. Triggers on: "how do I migrate from WorkOS", "replace WorkOS with Descope", "we're moving off WorkOS", "WorkOS to Descope", "switch from WorkOS", "our app uses @workos-inc/node / @workos-inc/authkit-nextjs / AuthKit / WorkOS SSO / Directory Sync / SCIM and we want to use Descope instead", or any question about WorkOS features (AuthKit, Organizations, Enterprise SSO, Directory Sync/SCIM, Admin Portal, RBAC, FGA, Audit Logs, Radar, Pipes) in the context of Descope. Works for any language or framework with a Descope SDK. Always use this skill before producing migration guidance — do not rely on memory alone.
development
Use this skill whenever anyone asks about migrating from Okta Customer Identity Service (CIS) to Descope — whether they're a developer doing it themselves or a technical lead evaluating the move. Triggers on: "how do I migrate from Okta", "replace Okta CIS with Descope", "we're moving off Okta", "Okta to Descope", "switch from Okta", "our app uses okta-auth-js / @okta/okta-react / @okta/okta-angular / @okta/oidc-middleware / okta-jwt-verifier and we want to use Descope instead", or any question about Okta CIS features (Sign-On Policies, Authorization Servers, Authenticators, Identity Providers, Log Streams, Service Apps, scp claim) in the context of Descope. Works for any language or framework with a Descope SDK. Always use this skill before producing migration guidance — do not rely on memory alone.
development
Set up and manage Descope projects with Terraform. Use when configuring authentication infrastructure as code, managing environments, creating roles/permissions, setting up connectors, or deploying Descope project configurations.
testing
Author, edit, or apply a Descope FGA schema using the ReBAC/ABAC DSL. Use this skill whenever the user asks to create a new FGA schema, modify an existing one, add types/relations/permissions/conditions, review an authorization model, or apply schema changes to a Descope project. Trigger even if the user says things like "set up authorization", "define roles and permissions", "add team-based access", "make this endpoint check FGA", or "update my authz model" — these almost always mean an FGA schema change.