DEV Community

Dang Tran
Dang Tran

Posted on

I vibe-coded a career app on 1,016 O*NET jobs: two Sanity Workflows and a mascot named Mot

Sanity Challenge Path Two Submission

This is a submission for the Sanity Challenge, Path Two: Vibe-Code Something Strange

What I Built

Mot is a career exploration app for job seekers, built on O*NET 31.0, the U.S. Department of Labor's database of about 1,000 occupations. Mot (a cross between Meta's Muse and OpenAI's Dots, made casual so it feels like a friend) has three tools:

  • Career Explorer: chat about careers, bookmark occupations, and compare them.
  • Mock Interview Coach: practice interviews graded against O*NET's own examples of each skill level.
  • Interest Quiz: a RIASEC interest quiz matched against every occupation, plus a sidebar of other career quizzes from around the web.

My Path One post covers the agents. This post covers the other half: the Sanity side I prompted into existence, and the people who aren't job seekers.

  • Career counselors write interview coaching guides in Studio. A guide only reaches the AI coach after another counselor reviews it in a Workflow.
  • Content managers vet career quizzes that anyone with Studio access can submit. A quiz goes through a second Workflow with gated review tasks, and approving it publishes it straight to the site.
  • I get a weekly Sanity Function that classifies every chat transcript, so I can see what people ask.

Everything except the Astro front end and the Node agent service lives in Sanity: more than 1,500 O*NET documents, the coaching guides, the quiz submissions, both workflow definitions, a Context Knowledge Base, two Context MCP endpoints, and the Function.

Demo

Live app: https://career-exploration-two.vercel.app/ (a Cloudflare Turnstile human check runs before the agents start)

Code

My Build Process

Tooling. I built all of it in Cursor's agent mode, across about 47 agent chats and roughly 80 commits in four repos, from September 19 to October 2. I wrote very little code by hand. My job was deciding what to build, writing the prompts, testing in the browser, and saying "no, not like that." Three things made the agent much more reliable, and I'd set them up earlier next time:

  • An AGENTS.md in every repo, plus one at the workspace root listing the cross-repo contracts. For example: "the quiz tool shapes in agent/src/quiz-agent.ts match the types at the top of web/src/components/InterestQuiz.tsx", and "QUIZ_FOCUSES in the Studio schema matches FOCUS_LABELS in the web component." Once these were written down, the agent stopped changing one side of a contract and forgetting the other.
  • Sanity's agent skills (create-agent-with-sanity-context, shape-your-agent, dial-your-context), vendored into the agent repo. My actual prompt was "Use the create-agent-with-sanity-context skill to help me build an agent in this project."
  • Cursor rules for my own bad habits: every new branch goes in a git worktree inside its app folder, and the agent never starts a dev server on main/master. I added the second one after the agent started a second Astro server on port 4322 next to mine.

Here's how it went, in order, with the prompts that mattered.

1. Modeling O*NET in Sanity (day 1)

I want to create schema for Onet data at https://www.onetcenter.org/dictionary/31.0/mysql/ in Sanity studio

O*NET ships as dozens of relational tables. The first decision was the important one: only reference tables and occupations become documents. Per-occupation rows (tasks, job titles, ratings, related occupations, interest scores) are embedded as arrays on onetOccupation, and element-level rows go on onetContentModelElement. The rest of the project works because of that choice. An agent can fetch one document and get the whole job, and GROQ can join a rating to its scale and its Level Scale Anchors with a single ->.

The importer (pnpm import:onet) downloads the tab-separated files, parses them, and upserts them in phases, with --dry-run, --limit, --phases, and --occupations flags so I could import 50 occupations and look at them in Studio before importing all 1,016. I later asked "is there any data I missed importing?" and then "I want all data to get imported", which added the detailed work activities, task ratings, and interest data in later phases.

The Studio structure puts the O*NET types under one O*NET section (Occupations, then Reference data), and the occupation document has tabs (Overview, Tasks, Job Titles, Software Skills, Ratings, Related, Interests, Survey Metadata), so a 1,000-row ratings array doesn't bury the description.

2. From "what should I build?" to three agents

I want to build an agent consume data from Onet, any ideas about agent I should build?

That gave me the Career Explorer. Then:

So right now, we have a Career Explorer tool, I want to add a new tool Mock interview coach and interest quiz, I think each tool will present in a separate page

At first the agent lived inside the Astro app. After a day I asked, "I want to separate agent into another workspace, it needs to stand out from web/", and it became its own Node service. The web app proxies to it through server routes, behind a Turnstile human check and a shared bearer token.

3. Finding a real job for a Knowledge Base

In Sanity, when creating a MCP endpoint, I need to point out the Content source, which comes with 2 options knowledge base and Dataset. What are the differences between two?

do you have any ideas that I can use Knowledge Base in our app?

The answer that stuck: career counselors write interview coaching guides, and a Knowledge Base turns them into the coach's playbook. O*NET itself stays on a dataset-backed endpoint because answers need exact codes and ratings.

One honest detail: the challenge page says beta Knowledge Bases index up to 150 documents, so I had the agent write that limit into AGENTS.md. Later, the Knowledge Base API reported a source limit of 5,000 for my organization. Now the docs tell future agents to check sourceUsage instead of trusting either number.

4. Workflows, round one: coaching guides

Can you come up ideas for the app where leverage Workflows in Sanity?

The idea I picked:

Review coaching guides before they reach the Interview Coach. Right now, publishing a coachingGuide sends it straight into the Context Knowledge Base, so one bad guide immediately…

I used @sanity-labs/sanity-plugin-workflows. The workflow is defined in code (coachingWorkflow.ts) and a setup script writes it to the dataset as a workflow.definition document. After that, Studio is the source of truth. It has two roles (Author and Reviewing counselor), tasks bound to those roles, and completion gating, so a guide can't leave Counselor review until "check that the Job Zones match the advice" and "check the guide agrees with the rating scale" are done.

What I got wrong: the first version had a fourth stage, Coach test, backed by a separate staging Knowledge Base that included drafts, so you could try a guide in the coach before publishing it. It looked careful on paper. In practice it used up a Knowledge Base slot. I also learned that a Knowledge Base refresh only files issues and doesn't rewrite entries, so publishing a guide doesn't change what the coach reads until someone applies those issues. That made the staging copy pointless. I removed the stage, moved the "run three mock answers in the coach" task to Approved as a post-publish reminder, and deleted the staging Knowledge Base.

What surprised me: when I published an overlapping guide, the refresh did flag it, but the dashboard's Issues page didn't show it. I only found it through the API. So I had the agent change pnpm kb:coaching to print every open issue (kind, severity, entry path, finding, suggested fix) right in the terminal.

Two setup gotchas, now in the Studio README: workflow tasks live in the comments addon dataset, so task creation silently does nothing until someone has added at least one comment in that Studio. And role-bound tasks only appear once the role has an assignee.

5. Workflows, round two: user-submitted career quizzes

I'd added a hard-coded list of "other career quizzes" to the quiz page. Then:

I want to have a new content type for quiz, so people can submit quiz, and submitted quizes will go through a review process

The careerQuiz type has a name, provider, URL (with a duplicate-URL check), description, focus, cost, list order, and submitter notes. The workflow is Draft → Ready to Review → In Review → Approved, with a Retired off-ramp for dead links or quizzes that start charging money. In Review gates on three Content Manager tasks: check that the provider is trustworthy (read the privacy policy, reject quizzes that sell data or push paid coaching before showing results), check the listing, and take the quiz end to end. A fourth task, "Try the quiz on a phone", is optional, because many job seekers only have a phone.

Then I hit a design problem. A workflow transition only changes the document's status. So a quiz could say "Approved" and still not be on the site. My prompts:

When the quiz is moved to Approved. It should be published

I think I don't need the step from Approved -> Published

The result is a custom document action (publishOnApproveAction.ts). It wraps the plugin's action so that at In Review, "Move to Approved" becomes Approve and publish. The plugin still runs its task gating and confirm dialog. When the transition succeeds, the action publishes the draft with client.action({actionType: 'sanity.action.document.publish'}). If publishing fails, the quiz stays at Approved, a toast says "Approved, but not published", and the regular Publish button is there to retry. In this Studio, "Approved" means "live on the site."

6. The bug the model couldn't see: "why can't I retire the quiz?"

In the browser, why can't I retire the quiz after I assigned myself as a content manager?

The Retired off-ramp is limited to the Content Manager role. I was a project Administrator and had assigned myself as Content Manager, and it still didn't work. The agent produced several confident theories about how the plugin maps project roles to workflow roles, none of which fixed anything. It also couldn't look inside the Studio, which renders in an iframe that Cursor's browser tools can't inspect.

What finally worked was me opening DevTools. After "I don't know how to select the frame in Dev Tools" and a short walkthrough, I found it:

Oh I see /acl requests return CORS error

still got CORS error, but the request URL is https://api.sanity.io/vX/projects/rhq335ze/acl

The workflow kit called the global management API host from the browser, the request failed CORS, and the role check quietly failed with it. The fix is a pnpm patch to @sanity-labs/workflow-kit@0.6.0 that calls the project host (https://rhq335ze.api.sanity.io) and also accepts a single-object response where the code expected an array. It's in the Studio repo under patches/. Lesson: when the model starts generating theories instead of evidence, give it evidence.

7. Things I asked for, then took back

Not every prompt was a good idea, and the agent did exactly what I asked each time:

  • Wider pages: "for interest quiz and career explorer pages, I want to widen them to be the same width with Mock Interview", then "please revert the change, I don't like the new layout."
  • A coaching guide panel and a Knowledge Base search box next to the Mock Interview, wired to the coaching MCP endpoint through a new agent route. I built it, used it, and decided job seekers didn't need to read counselor guidance: "Please remove the coaching guide section in Mock Interview page" and "please remove the unused agent endpoint." The guides still reach the coach, just not the visitor.
  • The "Thinking…" animation took four prompts, because "I want the dots in the thinking to be animated" means something different to everyone. What worked was spelling out the frames: "Thinking → Thinking. → Thinking.. → Thinking... → Thinking.. → Thinking. → repeat".
  • Animating Mot: "I do not see the animation. I do see the avatar change a bit for every refresh, but that's not what I want. And by the way, I only want the big Mot avatar on the front page to be animated." Then blinking eyes and a moving mouth, then "please remove the mouth animation."

The pattern: vague taste prompts took several rounds. Prompts that named the exact element (I often clicked it in Cursor's browser so the prompt included its DOM path), the exact behavior, and where it should go landed on the first try.

8. Small things that made it feel finished

  • Next-question suggestions: "After Mot responses, I want the placeholder for the chat will be a suggestion for the next user's question." The explorer ends each reply with a hidden <next-question> line. The UI strips it as it streams, shows it as the input placeholder, and Tab accepts it.
  • Hybrid search: after I turned on dataset embeddings for onetOccupation, the search tool merges keyword and semantic rankings. "AI specialist" used to return Animal Breeders second (artificial insemination). It doesn't anymore.
  • A RIASEC explainer on the quiz page, so "your code is SIA" means something.
  • Repo hygiene: npm to pnpm (the workspace was huge), then the three app repos became git submodules of the workspace repo.

App SDK

I didn't use the App SDK. I put the custom work into the Studio itself: two workflows, a custom document action, a patched plugin, custom structure, and setup scripts that create Knowledge Bases and workflow definitions through the API. A small App SDK dashboard of the quiz review queue would be my next step.

Sanity Project Details

Published career quizzes, straight from the public dataset:

https://rhq335ze.apicdn.sanity.io/v2025-02-19/data/query/production?query=%2A%5B_type%3D%3D%22careerQuiz%22%5D%7Bname%2C%20provider%2C%20focus%2C%20cost%2C%20status%7D

Agent Session

I vibe-coded a career app on 1,016 O*NET jobs - Quiz-retire session cursor-agent
You

In the browser, why can't I retire the quiz after I assigned myself as a content manager?

Agent

I'll look at how the Studio decides who can retire a quiz.

I'm wondering if retiring a quiz after self-assigning as content manager relates to a Sanity Studio document action, like a "Retire" action gated by role permissions — let me look at the studio code to check.

Agent
Agent
Agent

The Retired off-ramp is limited to the content_manager workflow role. Next I'll check how the workflows plugin enforces allowedRoles.

The off-ramp restricts to content managers, but the project roles mapped to that are administrator and editor—so the user's self-assigned content manager role might not align with how the plugin checks permissions. I should check whether the plugin validates against the deployed workflow definition in Sanity rather than the local file, since Studio could be the actual source of truth, so let me dig into the plugin source.

I'm also wondering if there's a hidden constraint forbidding the submitter from also being the content manager who retires it—comparing against the coaching workflow might clarify this, so I need to examine the plugin's logic directly.

Agent
Agent
Agent

The off-ramp check never receives the document's assignments, only project users and ACL data. Next I'll read canUseOffRampStage in workflow-kit to confirm.

Interesting — canUseOffRampStage doesn't check document assignments at all, it relies on aclData/projectUsers and workflowRoles, mapping project roles to workflow roles. So assigning myself in Assignments is irrelevant; what matters is whether my project role falls under content_manager's projectRoles (administrator, editor). I should dig into canUseOffRampStage in workflow-kit.

Agent

So off-ramp permission depends on project role mapped to the workflow's projectRoles rather than assignments, so if Dang holds an administrator role on the project he'd likely pass unless the deployed workflow definition in Studio differs—maybe the content_manager projectRoles or allowedRoles slug was edited, or the ACL itself is different from what I'm assuming.

I should verify by checking the actual deployed definition against the user's real role, and also check if there's a patch on workflow-kit affecting this. Since the user mentioned seeing this in the browser, I should look at the disabled tooltip there directly, but I'd also need to query the dataset for workflow.definition documents—though dotted-path IDs like these aren't publicly readable even on a public dataset, so I'll need a token, possibly from the studio's .env file, before resorting to checking the browser first.

Permission for the Retired off-ramp is governed by your Sanity project role, not the document's Assignments field. I'll now check the Studio tab and the workflow definition in the dataset, since Studio is the source of truth.

Agent
Agent

The quiz's status already reads Retired, but it still has a Published version. I'll open the document actions menu to see what the Retire action reports.

It's odd that the status already shows "Retired" but the document still appears Published with both chips visible—maybe the retire action succeeded on the draft but the unpublish step failed, or the UI is just showing conflicting states. I want to check the actions menu near Publish to see the "Move to Retired" option and its tooltip to understand what's actually blocking the transition.

Agent

The Studio renders inside an iframe I can't inspect, so I'll query the dataset directly with the Sanity CLI for the quiz, the stored workflow definition, and your project role.

The studio is inside an iframe I can't access directly, and this deployed version might not match the local code. I should instead query the Sanity data directly using an authenticated CLI command rather than relying on the studio UI.

Agent
Agent
Agent
Agent

The CLI query likely defaulted to published perspective, excluding drafts, since the API defaults changed in 2025. I'm also noting the logged-in user is Dang Tran, an Administrator, while the "Content Manager" assignment displays an email address instead of a proper name.

Checking whether that stored value is actually a user ID or email—the "g49nu4KCz" user isn't even listed among project users, suggesting a raw ID mismatch. Dang as Administrator should map to content_manager and pass the canUseOffRamp check, assuming findProjectUserForCurrentSanityMember correctly matches the project user id to the current session's sanity user id.

I'm now focusing on the draft's status being "Retired" already, which disables the retire action—so the earlier transition attempt likely patched the draft to retired but then silently failed when trying to unpublish the document.

The unpublish action could fail for a few reasons: strong references from other documents, a permissions issue, or something else entirely. I want to pull the raw draft data and check for incoming references, since that would explain why the published version never actually got unpublished despite the draft showing "Retired."

Your Administrator role isn't the issue—the stored workflow maps both Administrator and Editor to Content Manager. I'll now check the draft, which was hidden by the CLI's default published perspective.

Agent

I could use sanity exec with a script, or alternatively try sanity documents get drafts.<id> to fetch the draft directly.

Agent

That's odd—the query shows published status but the screenshot shows "Retired" at a later timestamp. The transition may have happened between my query and the screenshot, or the badge could reflect a pending action rather than the final state. I should re-query to check if the status actually changed after 03:48.

Agent

I'm trying to figure out the status field layout - it seems "Retired" is an inactive off-ramp button rather than the current state, with the record showing Published. I'm now checking who the logged-in user actually is, since the avatars and initials in the corners might indicate someone other than Dang, which would affect whether the Administrator permissions apply.

Actually, I'm realizing the assignment data is the real clue: one entry stores a raw email instead of a resolved user ID, and another shows a raw ID with no name or avatar, suggesting the project users list isn't loading correctly. That would make canUseOffRamp evaluate false, producing the "Only Content Manager roles can use this off-ramp" disabled state — so I want to dig into how useWorkflowProjectUsers resolves and caches project members.

Found that the "Retired" badge is actually the off-ramp button, not the real status—the document is still published since retire never ran, and assignments show raw IDs instead of names, hinting the plugin can't load project users. I'll check how it fetches those users next.

Agent

If the project-users fetch fails, the hook quietly falls back to an empty ACL, and the off-ramp then stays disabled for everyone. Next I'll check how it calls the Management API.

I'm realizing the patch handles the single-ID case for the users endpoint, and pending invitees wouldn't appear in the ACL data anyway. If the fetch fails, the ACL array defaults to empty, which disables the off-ramp — so I need to check whether the deployed Studio build actually includes this patch or predates it.

Now I'm puzzled by the three real user IDs and one odd ID ("g49nu4KCz") that doesn't match any project member, plus an email address showing up where a userId should be — maybe the assignment picker accepts free text input rather than validating against actual members. Rather than keep guessing, I should trace the actual management API calls the plugin makes, checking how it authenticates (CLI token vs cookie-based session) when running in the deployed Sanity dashboard.

Agent
Agent

I'll make the same two Management API calls the plugin makes, using your CLI login, to see what they return.

I should grab the CLI auth token via the secrets command, then use it to call the project's ACL and users endpoints on the management API, making sure to include the correct API version header.

Agent

Auto-review blocked pulling your CLI token. Instead I'll run the plugin's requests from inside the Studio frame in the browser, using your existing session.

Targeting the iframe directly via CDP seems overly complex, so maybe I should just stick with sanity users list, which already surfaces the ACL roles I need without fighting the browser context.

Agent

The CDP session seems attached to a different frame, which isn't reliable to chase further. I should probably reason it through without more API calls and instead check what version of workflow-kit the sanity-plugin-workflows 0.8.0 depends on internally.

CDP connected to a different page (localhost:4321/quiz) rather than the Studio frame, so I can't evaluate code there directly. I'll instead check which workflow-kit copy the plugin actually loads, since the patch only targets one version.

Agent

There are two copies of workflow-kit, one patched and one not, due to differing peer resolution. I need to check the plugin's real path and its sibling node_modules to figure out which copy actually gets resolved.

Agent

The plugin loads the patched workflow-kit, and the deployed build is newer than the patch. Next I'll check what the assignment picker stores, since an email in userId is unusual.

The deployed build looks fine timing-wise, so now I'm wondering why canUseOffRamp would be false for Dang as administrator. Maybe the fetch for ACL data is failing, or maybe the browser is actually logged in as a different user like Contributor One rather than Dang—I should check the avatar in the top right to confirm identity.

Also the "g49nu4KCz" ID looks like a global Sanity user ID rather than a project-scoped one, which would happen if the user picker's project-users fetch failed and it fell back to showing the raw sanityUserId instead of resolving it properly. That would confirm the fetch-failure theory, so I want to check the assignment input component and the setup script to see how it stores and displays assigned users.

Agent
Agent
Agent
Agent
Agent

Since neither the email nor the ID resolved, that suggests projectUsers is empty on the client side, meaning the useWorkflowProjectUsers hook likely failed silently.

But I should double-check my assumptions — his account email might not be [email] if he logged in via Google with a different address, and g49nu4KCz could actually be his sanity sanityUserId rather than project user ID. I could run sanity debug without the --secrets flag to safely check his actual user ID, name, and email.

The assignment preview couldn't resolve either userId, suggesting the project-user list is empty in the browser. I'll verify your account's global ID and email to confirm they should have matched.

Agent

Since Dang's email renders raw instead of as "Dang Tran" with an avatar, that confirms projectUsers and aclData are both empty in the deployed studio, which is why canUseOffRamp is false. Now I'm trying to figure out why the fetch itself fails, thinking about how the studio's cookie-based credentials work against the api.sanity.io dashboard.

One possibility is that in the dashboard-embedded version, the studio relies on a token stored in localStorage via resolveStudioAuthToken rather than a cookie — if that token never gets written to localStorage in this embedded context, the request would have neither a valid cookie nor a token, causing a 401. I want to check resolveStudioAuthToken to confirm this theory.

The project-user list came back empty when it should have shown your assignment, so I'll check how the plugin authenticates that request.

Agent

I'm wondering if the API call is failing due to a token or version mismatch, but without console or network access I can't confirm it directly. Running the local studio instance might help verify, though I should avoid starting a dev server while on the main branch.

I'm considering whether I could navigate to the deployed studio's own host in a separate tab to inspect things directly, rather than disrupting the user's current dashboard view — I'd need to check the studio's configured host first.

I found that the plugin authenticates via a cookie or localStorage token against the Management API, silently swallowing errors. Next, I'll locate Studio's direct host so I can load it as a top-level page and inspect the request directly.

Agent

The studio host is unclear, but since CDP attached to localhost:4321/quiz, I'll try querying the document for an iframe element and grabbing its src to find the actual target.

Agent

CDP is attached to an old page instead of the Studio tab. I'll try once more in a fresh tab before relying on the evidence I already have.

The CDP connection seems stuck on a stale page, and opening a new tab likely just hits the same iframe issue. I'm trying to figure out if there's a direct studio hostname or CLI command—like deployments list, manifest, or debug—that could help me find the actual deployed URL instead.

Given time constraints, I think I have enough to provide a solid answer with a clear caveat about verification, suggesting the user check DevTools Network as a fallback. I'll try one more quick approach: opening a new tab directly to the studio's careerQuizzes structure URL and attempting CDP on it, though the same binding issue may recur.

Agent
Agent

The Studio's direct host is careers-exploration.sanity.studio. I'll load it top-level in the new tab and run the plugin's two requests the same way the plugin does.

The studio host's "#sid=..." fragment looks like a session handoff — the dashboard likely passes a session ID that the studio exchanges for a token, since third-party cookies won't work inside an iframe. I suspect the studio stores that resulting auth token in localStorage under a project-specific key like __studio_auth_token_<projectId>.

To test this, I want to navigate to the studio URL directly at the top level rather than through the dashboard iframe, then check whether the Retire button is enabled there — if so, that would confirm the dashboard's iframe embedding is what's blocking it.

Agent

The direct Studio URL redirects back into the dashboard, so I'll try to evaluate inside the Studio iframe itself.

Since the studio redirects to the dashboard, top-level evaluation won't work. I should try using Page.createIsolatedWorld with the frameId from this tab's frame tree instead, though the iframe being cross-origin means it's likely an OOPIF not present in that tree.

Agent

The Studio iframe is out-of-process, so I can't reach it this way—I should close the extra tab I opened and unlock the original. The root cause seems to be that the retire off-ramp checks permissions against project role through the Management API ACL rather than assignments, and Studio's project-user fetch isn't resolving properly, as shown by both assignment previews failing to resolve.

This leaves canUseOffRamp false, disabling the button with "Only Content Manager roles can use this off-ramp," even though nothing actually changed—status stays "published" in both draft and published versions, and the workflow definition and role look fine. The likely culprit is the embedded dashboard's auth handoff: Studio authenticates via a session ID handoff while the workflow-kit fetch relies on credentials being included, and cross-site cookie partitioning between sanity.studio and api.sanity.io inside the dashboard iframe could be blocking the token—though I can't fully confirm this hypothesis.

I notice a secondary oddity with an unrecognized user ID that doesn't match the project's user list, but it's likely minor and won't affect the retire issue, so I'll mention it only briefly if at all. The practical next step is to have the user check the Network tab for the ACL request in the Studio frame, or try running Studio locally via sanity dev with cookie auth on localhost to sidestep the iframe issue.

Agent

Assigning yourself as Content Manager doesn't affect the Retire button. The workflow plugin decides who can retire based on your Sanity project role, not the document's Assignments. The deployed Studio seems unable to load the project's member list, so it can't confirm your role and keeps the button disabled.

Nothing has been retired. The Retired badge under the status bar is the button itself, not the quiz's status. The quiz is still published in both its draft and its live version, and it's still listed on /quiz.

The permission check ignores Assignments. The off-ramp is set up with allowedRoles: ['content_manager']. The plugin's check (canUseOffRampStage in @sanity-labs/workflow-kit) takes your Sanity user, the project's member list and the access-control list (ACL), but never the document's assignments. It looks up your project role and checks it against the workflow role's projectRoles:

        return (workflowRole.projectRoles ?? []).some((projectRole) => aclEntry.roles?.some((aclRole) => aclRole.name === projectRole));

Your role and the workflow setup are fine. You're an Administrator, and the workflow definition stored in the dataset maps Administrator and Editor to content_manager. You should pass this check.

Why it still fails: the member list comes back empty in the browser. The plugin fetches the members and ACL from the Sanity Management API. If that request fails, it silently falls back to empty lists, and the check fails for everyone. Your Assignments field shows this happening. The preview matches each assignee by project user ID, global user ID or email, and your CLI account is [email]. With the list loaded, that row would show "Dang Tran" with your avatar. It shows the raw email instead.

I couldn't confirm why the request fails. The Studio runs inside the sanity.io dashboard as a cross-origin iframe, which my browser tools can't reach, and loading careers-exploration.sanity.studio directly redirects back into the dashboard. My best guess is authentication. The plugin calls https://api.sanity.io/vX/projects/rhq335ze/acl using a cookie or a token saved in the Studio's local storage. In the embedded Studio, which signs in through a session handoff in the URL, it may have neither. Auto-review also blocked me from making the same call with your CLI token.

To check, open DevTools on the Studio tab, select the careers-exploration.sanity.studio frame, and look in the Network panel for the vX/projects/rhq335ze/acl request. If it's a 401 or blocked by CORS, that's the cause. Hovering the Retired button should also show "Only Content Manager roles can use this off-ramp."

One more detail: the other assignee, g49nu4KCz, isn't a member of this project, so that assignment won't resolve to a name even once the member list loads.

That view ID now points to localhost:4321/quiz, so the original studio tab should still be around elsewhere. I'll go ahead and write the answer.

You

Still can't retire the quiz

Agent

I'll re-check the quiz data first, then look at how the Studio stores its auth token when it runs inside the dashboard. That's my best lead for why the plugin can't load the member list.

Still retiring, I'm considering whether to bypass the UI with a direct script/CLI patch that sets status to 'retired' and unpublishes, versus digging deeper into the actual root cause of why retirement isn't working.

I'm now wondering about the auth mechanism itself — whether the Studio client uses a bearer token or cookie-based auth, since that changes how I'd debug the network request. Running a local Studio instance via a worktree might let me inspect the actual network calls through CDP on a top-level page instead of fighting the iframe restrictions, though I need to confirm whether localhost auth works the same way with CORS and cookies.

Given third-party cookies are likely blocked, Studio probably falls back to a token stored under a specific localStorage key, and the dashboard version might store that token differently or in-memory, meaning the plugin can't find it and falls back to a blocked cookie — producing the 401. I want to verify this by checking the sanity package source for how that localStorage key is set and how dashboard auth differs.

Agent

The quiz is still published. You also published again at 04:03 UTC, which cleared the draft, and the first assignment changed to ggGiFZPPz. Next I'll check how Studio stores its token.

Only one document shows now, which means the draft got published and no longer exists separately — the assignments shifted to ggGiFZPPz and [email], still marked as published. That g-prefixed ID looks like it could be a global Sanity user ID, possibly tied to Dang if they selected themselves through some picker.

I should check how the auth token is stored in Sanity to confirm this.

Agent
Agent

I'm trying to figure out where the token gets stored in localStorage, checking if it matches the plugin's stored auth token reader, and wondering whether dashboard mode instead pulls the token via comlink and keeps it in memory rather than localStorage. I should look into how the token-mode gets selected and whether dashboard storage behaves differently.

Agent
Agent

I'm also considering observeWorkbenchToken, which is how dashboard mode might supply Studio with the workbench's auth token held in memory rather than localStorage. That raises the question of which client the plugin actually uses via useClient(), since if that client already carries the token, getClientAuthToken would just return it directly for the fetch.

You

Can you find another quiz that I can input?

Agent

I'll list every career quiz already in the dataset, including drafts and retired ones, so I don't suggest a duplicate.

The user wants another career quiz added to Studio, so I need to review the eight quizzes already listed and propose a new one not currently included, with details like name, provider, URL, description, focus, and cost.

I should also check for any draft or unpublished quiz documents to avoid duplicates, querying with an appropriate API version to include drafts. For the new addition, CareerOneStop's Work Values Matcher looks promising since it covers "work values," a focus area none of the existing quizzes address, though I still need to confirm the URL is correct.

Agent

No quiz covers the "Work values" focus yet, so I'll check that CareerOneStop's Work Values Matcher is live at the URL I expect.

I don't have a dedicated values-focused quiz yet, so I'm considering CareerOneStop's Work Values Matcher as a candidate, but I need to verify its URL before including it.

Agent
Agent

CareerOneStop blocks the fetch tool, which it does for its existing Interest Assessment page too, so I'll check with curl using a browser user agent.

CareerOneStop seems to block bots since it's on the same domain as the existing Interest Assessment, so I'll try curling it with a browser user agent instead.

Agent

CareerOneStop answers curl with a bot challenge (HTTP 202), so I'll load the page in the browser instead.

The 202 looks like a bot challenge from Akamai. Since I can't verify the Work Values Matcher URL through curl, I'll try navigating to it directly in the browser tab and taking a screenshot instead.

Agent
Agent
Agent

This is the Work Values Matcher - free, no login needed, 20 cards sorted into 5 importance levels. The URL redirects straight to the quiz, so I'll point to that link as the direct access point.

I'll assign it listOrder 35, slotting it next to the skills entry, and write a short description about sorting job-value statements to see matching careers. My submitter notes flag that it fills a work-values gap no other listed quiz covers, is free with no account required from the same provider as the Interest Assessment, though I haven't verified the results page itself.

Agent

Try CareerOneStop's Work Values Matcher. No quiz in the dataset uses the "Work values" focus yet, so it fills a real gap. Its URL isn't used by any existing quiz, including drafts and the retired O*NET Interest Profiler, so the duplicate-URL check will pass.

I loaded it in the browser. It opens straight into the quiz with no login: you sort 20 statements such as "I use my talents and abilities" into five importance levels. I didn't take it through to the results page.

Here's what to enter in Studio:

Field Value
Quiz name Work Values Matcher
Provider CareerOneStop
Quiz URL https://www.careeronestop.org/toolkit/careers/work-values-matcher.aspx
Description Rank 20 statements about your ideal job by importance, then see careers that match the work values you care about most.
Focus Work values
Cost Free
List order 35 (places it right after mySkills myFuture)
Notes for reviewers Fills the Work values focus, which no listed quiz covers. Same provider as the Interest Assessment. Free and no account needed.

The description is 119 characters, under the 200 limit. The URL redirects to work-values-matcher-assessment.aspx with an odd query string, so I'd keep the clean address above. It matches the style of your existing Interest Assessment link.

If you'd rather add a different kind of quiz, CareerOneStop's Skills Matcher is another option. It overlaps with mySkills myFuture, though, and CareerOneStop blocked my automated check on it, so I haven't confirmed it loads.

The Retire button on Interest Assessment is still unresolved. When you want to come back to it, the next step is to check the api.sanity.io/vX/projects/rhq335ze/acl request in DevTools from inside the Studio frame.

I should provide the relevant fields, noting that the retire question is still unresolved since the user has shifted topics.

Agent
Agent
Agent

The background trace makes my "missing token" theory for the Retire problem unlikely.

Inside the dashboard, Studio does get an in-memory session token from sanity.io and puts it on its API client. The workflow plugin reads that same client's token (client.config().token) for its Management API calls, so the request should be authenticated.

So I still can't explain why the project-member list comes back empty, which is what keeps Retire disabled. Two possibilities are left. The vX Management API may reject that dashboard session token for the /projects/rhq335ze/acl endpoint. Or the request may be blocked by CORS (the browser rule that controls which sites can call an API) from the careers-exploration.sanity.studio origin.

Either one would show up in DevTools. Select the careers-exploration.sanity.studio frame, open the Network panel, and check the status of the vX/projects/rhq335ze/acl request. Paste that status and the response here, and I can take it from there.

Actually, tracing through useClient(), the workbench token should flow into the client config and be available to the plugin as a Bearer token through withConfig, which weakens the token-missing hypothesis—so I need to look elsewhere for why this breaks.

So the token hypothesis gets weaker; maybe the session token just lacks permission for that Management API endpoint, something only an Administrator token would have. Remaining suspects are the API rejecting the session token or a CORS issue with the sanity.studio origin, so the next move is still to inspect the actual network request in DevTools.

You

I don't know how to select the frame in Dev Tools

Agent

You don't need to select the frame for this. The Network panel already lists requests from every frame on the page, including the Studio iframe. Frame selection only matters in the Console.

  1. Open the Studio tab with the Interest Assessment quiz and press F12, or Ctrl+Shift+I, to open DevTools.
  2. Click the Network tab.
  3. Reload the page with Ctrl+R while DevTools stays open. The plugin only fetches the member list once per page load, so the request won't appear unless you reload.
  4. Type acl in the Filter box at the top of the Network panel.
  5. Click the acl row that appears:
    • The Headers tab shows the Status Code, such as 200, 401 or 403.
    • The Response tab shows what the API sent back.
  6. If the status is 200, clear the filter and type users/ to check the second request the same way.

If no acl row appears after the reload, open the Console tab and look for a red error mentioning api.sanity.io or "CORS." The Console shows errors from all frames by default, so you don't need to change anything there either.

Send me the status code and the response, or a screenshot, and I'll tell you what's blocking the Retire button.

I realize Network doesn't require frame selection since it captures requests from all frames including iframes — just filter by "acl". The frame selector only matters for Console. So I'll give simple steps: open DevTools with F12, click Network tab, then reload the page with DevTools open to catch the requests.

After reloading, type "acl" in the filter box, click the request to check the Headers tab for status and Response tab for the body, and also check the "users/" request if acl returns 200. If nothing shows up, it could be a CORS issue — worth checking the Console for red CORS errors, which also show by default across all frames. I'll note that the request only fires once a quiz doc is opened after reload, and keep instructions generic since I don't know if they're using Chrome DevTools or something built into Cursor's browser.

You

Oh I see /acl requests return CORS error

Agent

A CORS error means the browser blocked the request before your role was ever checked. The usual cause is that the Studio's origin isn't in the project's allowed CORS origins with credentials enabled. I'll list the project's CORS settings.

I'm seeing a CORS error hitting api.sanity.io's project ACL endpoint from the Sanity Studio origin. Since this request includes credentials and an Authorization header, I'm wondering if the issue is that the origin isn't registered in the project's allowed CORS origins, even though this is the global management API rather than the project-scoped one.

Deployed studios on *.sanity.studio should normally get added automatically with credentials when running sanity deploy, so I want to run sanity cors list to verify what's actually registered.

Agent

I notice only localhost:3333 is registered, not the deployed studio origin. I'm wondering whether the Studio itself still works via token auth without needing CORS, since sanity.studio domains might get special treatment, but I'm not fully certain CORS is bypassed even for token-authenticated requests.

The fix is to add the deployed studio's origin with credentials enabled via Sanity's CORS settings. This changes a shared project config, so I'm weighing whether to just do it since it's clearly the right setting for their deployed Studio and is reversible, versus asking first — I'll lean toward proceeding and explaining how to revert if needed.

The project's CORS settings only allow http://localhost:3333, so the deployed Studio origin https://careers-exploration.sanity.studio is being blocked from making credentialed requests to the Management API. I'll add that origin with credentials enabled to fix it.

Agent

I added https://careers-exploration.sanity.studio to the project's allowed CORS origins, with credentials allowed. Before this, the only allowed origin was http://localhost:3333. That's the fix, but I can't see inside the Studio frame to confirm it, so it needs a reload on your side.

The browser blocked the plugin's request for the project's member list from the deployed Studio. When that request fails, the plugin treats the member list as empty. It then can't confirm you're an Administrator, so it disables Retire for everyone. That's also why your assignments showed raw IDs and an email instead of names.

To check:

  1. Reload the Studio tab.
  2. In the Network panel, the acl request should now return 200.
  3. Your assignment should show "Dang Tran" with your avatar instead of the email.
  4. The Retired button should be enabled. Clicking it moves the quiz to Retired and unpublishes it, and it drops off /quiz within a few minutes.

If you ever need to undo this, run sanity cors delete https://careers-exploration.sanity.studio from studio/.

The iframe origin is careers-exploration.sanity.studio, which checks out fine. Since I can't verify this myself from within the iframe, I should ask the user to reload and retry, and mention the revert command as an option.

You

still got CORS error, but the request URL is https://api.sanity.io/vX/projects/rhq335ze/acl

Agent

The request goes to the global api.sanity.io host, not the project host rhq335ze.api.sanity.io. Project CORS settings may not cover that host. I'll send the browser's preflight check (an unauthenticated OPTIONS request) to both hosts and compare the responses.

I realize the request actually hits the global api.sanity.io rather than the project-specific subdomain, which has its own CORS policy separate from project-level origin settings. I should test this directly with a CORS preflight request to the global API endpoint.

Agent

The global api.sanity.io host doesn't send allow-credentials:true and blocks the GET with a 403 CORS error, while the project-specific host works fine with expected 401s. Since the plugin uses credentials:"include", the browser blocks requests to the global host—so the plugin's MANAGEMENT_API_BASE setting must be the culprit.

I'm wondering whether this is a propagation delay with the CORS origin config rather than a permanent issue, since the project host already reflects the right settings. The safer fix is probably patching workflow-kit's base URL to use the project-specific host instead of the global one, but I want to check the actual code defining MANAGEMENT_API_BASE_URL before making that change.

Found it: this is a plugin bug — the global host doesn't return access-control-allow-credentials: true, so the browser blocks the plugin's credentials: "include" call, while the project host works fine and returns the header. I'll check where the plugin builds the global URL.

Agent

The grep tool is behaving oddly with that path, possibly due to .cursorignore or node_modules exclusions, so I'll try using rg directly in the shell instead.

Agent

I'm modifying buildManagementApiUrl to accept a projectId parameter so it can construct the project-specific host URL when one is known, since requestManagementApi already passes projectId through its options.

I need to update the call site and check other places that call buildManagementApiUrl, plus confirm the project host properly handles CORS for the users endpoint. For deployment, since the fix only takes effect once the Studio is redeployed, I should finish patching with pnpm patch-commit, verify the build, and then check with the user before deploying to production rather than doing it unilaterally.

The plugin hardcodes https://api.sanity.io, so I'll extend the workflow-kit patch to use the project's own API host when a project ID is known. First I'll verify the users/ endpoint behaves the same on that host.

Agent

The project host allows credentialed requests for both endpoints the plugin uses. I'll update the patch now.

I'm fine editing directly on the main checkout since status is clean and I won't commit the change myself. Now I'll run pnpm patch for the workflow-kit package in the studio directory, allowing full network access since it may need to fetch from the store.

Agent

Your existing patch is already applied in the edit copy (line 134). Now the host change:

Agent

I should ignore the staging URL. Checking if there are other callers of buildManagementApiUrl or other fetches referencing MANAGEMENT_API_BASE_URL—only the one line found, so I'm ready to commit the patch.

Agent

The patch is committed to patches/ and the lockfile. I'll confirm the plugin resolves the new patched copy and that the Studio still builds.

I should confirm the plugin picks up the patched copy and run a build to make sure it compiles, checking package.json scripts for the right command. Since deploying with sanity deploy pushes live to production, I'll verify the build succeeds first and then check with the user before deploying.

Agent

The build time of 710ms seems oddly fast, but that's probably fine since Sanity loads from a CDN with auto-updates. I want to confirm the new URL actually made it into the dist bundle.

Agent

Deploying to production is a significant action, so I should check with the user before proceeding rather than assuming it's safe to go ahead.

I traced the disabled Retire button to the plugin calling the global API host, which blocks credentialed requests from the Studio origin — the project's own host already allows it. I've patched @sanity-labs/workflow-kit to use the project host when available, confirmed sanity build succeeds, but can't verify the button itself until the new build is deployed.

Agent
Agent

Deployed. I'll check that the live bundle includes the fix.

I should verify the deployed bundle actually has the fix by fetching the studio index page to locate the bundled JS file reference.

Agent
Agent

The fix is live on careers-exploration.sanity.studio. The deployed bundle now sends the plugin's member-list requests to https://rhq335ze.api.sanity.io, which allows the Studio's credentialed requests.

To check that Retire works:

  1. Hard-reload the Studio tab with Ctrl+Shift+R so the browser fetches the new bundle.
  2. In DevTools, the acl request should now go to rhq335ze.api.sanity.io and return 200 instead of a CORS error.
  3. On Interest Assessment, the assignment should show "Dang Tran" instead of your email, and the Retired button should be clickable.

The patch change in studio/patches/@[email] and the updated pnpm-lock.yaml aren't committed yet, and studio/ is on main. Commit them, or have me put them on a branch, or a future reinstall and deploy will bring the CORS error back.

The deployed bundle now contains the fix, so I should tell the user to do a hard reload and try again, while also flagging the uncommitted changes in the studio repo.

You

It works

Agent

Good, so Retire works now that the Studio can load the member list again.

The change behind it still isn't committed. It's the workflow-kit patch in studio/patches/@[email] plus the matching hash in pnpm-lock.yaml. Until it's committed, a fresh pnpm install and deploy from another checkout would bring the CORS error back.

The project-level CORS origin for https://careers-exploration.sanity.studio is already saved in Sanity, so it doesn't need committing.

I'll keep this short—just confirm things look good and offer to commit the change.

Agent
Agent
Agent

I committed the patch on main in studio/ as fc7fd1e, "Call the project API host for workflow user lookups." It covers the updated patch file and the new patch hash in pnpm-lock.yaml.

The commit is local only; I haven't pushed it. Push it before anyone else reinstalls and redeploys the Studio, or the CORS error will come back.

You

When the quiz is moved to Approved. It should be published

Agent

I'll look at how the Approved stage and the custom publish action work today before changing anything.

The user wants moving to Approved to auto-publish, rather than requiring a separate "Move to Published" step. I need to look at the actions folder and workflow config to see how publishApprovedQuizAction currently works before changing the transition logic.

Agent
Agent
Agent

The custom action only covers the step from Approved to Published. Next I'll check how the plugin's "Move to Approved" button works, to see whether it can be wrapped the same way.

I'm thinking through whether moving to Approved should merge with publishing, essentially collapsing Approved and Published into one step, or whether Approved should stay a distinct status while triggering the publish action separately. I need to clarify what "move to" actually means in this workflow before deciding.

But if status stays at Approved and publishing happens, the Published stage never actually gets used, which seems confusing for the workflow design. I need to understand how the plugin's transition button actually works — whether it's gated, how it triggers performWorkflowTransition, and whether wrapping its dialog/confirm flow is feasible, versus using a server-side document function triggered on status change to approved that publishes via the actions API — though that introduces async delay and deployment complexity. I should look more closely at the plugin's action code to see its structure before deciding.

Agent
Agent

At In Review, the plugin action should show "Move to Approved" with its gating and confirmation dialogs, then after executeTransition completes, I can wrap the onComplete callback so that when the draft is in_review, the wrapped callback publishes it after the original completion logic runs.

I'll extract a shared publishApprovedQuiz() helper and reuse it in the wrapped onComplete for In Review, since it mirrors what the existing Approved branch already does. I need to be careful that by the time onComplete fires, the draft snapshot is stale (status still shows in_review), so I should refetch the draft from the client before running the approved→published transition to keep the audit history accurate, and I'll need access to the client, currentUser, and toast hooks in the wrapper component. I'm also thinking about validation — publishing requires no errors, so the "Move to Approved" action at In Review should probably be disabled when hasErrors, just like the Approved branch does.

Beyond disabling the button, I'm considering renaming it to something clearer like "Approve and publish" while keeping the existing gating and confirmation dialog flow intact.

Now I'm weighing the downstream effect on the Approved stage itself — quizzes will pass through it almost instantly now, so the stageCriteria text shown in the confirm dialog ("Ready to go live...") would be misleading and should probably be updated to reflect that approving immediately publishes. Since the workflow definition lives in Studio as the source of truth but the setup script seeds it into the dataset, I need to check whether pnpm setup:quizzes overwrites existing docs before deciding whether to patch the dataset's Approved stageCriteria directly alongside the file change. I also want to preserve the existing Approved branch logic as a recovery path — if the transition to Approved succeeds but publish fails, the quiz stays there and the Publish button lets someone retry. Finally, I should check whether StatusPathInput lets someone click directly on the Approved stage in the status bar, which could bypass this whole flow, so I'm going to grep the workflow-kit studio code for an onClick handler there.

Found that the "Move to Approved" button runs a gating check and confirm dialog before calling onComplete(), so I can hook into that to reuse the Approved stage's transition-and-publish code. Next I'll check if the status bar offers another way to change stages and how the setup script writes the workflow definition.

Agent
Agent
You

I think I don't need the step from Approved -> Published

Agent

So the user wants to drop the Published stage entirely, making Approved the final live state. I need to update the workflow definition so Approved itself enables publishing, and revise the stage criteria accordingly.

For the transition logic, I'm rerouting things so that moving to Approved from In Review triggers publish automatically, and at Approved itself the normal gated publish action stays available for quick fixes since enablePublishing is on. I also need to migrate the 8 existing quizzes currently marked "published" over to "approved" in the dataset, update the workflow definition document via the setup script's replace mechanism, and check whether web or other consumers filter by status "published" that would now need to reference "approved" instead — plus verify the status bar UI doesn't let users skip straight to Approved bypassing the publish flow.

Good, that simplifies things—Approved will be the final, live stage and Published goes away. I'll now check the background result, setup script, and anything else referencing the published stage slug.

Agent

The Grep tool seems unreliable across workspaces—it missed "retire" earlier even though rg found it in studio. I'll just use rg directly via shell instead.

Agent

I'm checking whether clicking a stage directly in the status bar (handleStageSelect) bypasses the normal gating logic, letting users jump statuses like "Approved" without going through the proper action/publish flow.

The web app doesn't filter by stage, but Studio's status bar has its own handleStageSelect that may change status outside the document action—I'll check that code next.

Agent

I'm noting that the status bar already lets permitted users jump to any stage, including Approved, without actually publishing — it just sets the draft status, similar to how jumping to Published used to work. Clicking Approved would set that status on the draft while leaving a normal Publish button active, which is acceptable but doesn't fully enforce the "should be published" expectation at that stage with an unpublished draft.

Now I'm sketching out a wrapper function that intercepts the completion step of the in-review-to-approved transition: instead of just marking the draft approved, it also triggers the publish action afterward, so the document becomes live with "approved" status once the transition completes.

I realize there's an edge case — if a published quiz gets sent back to review and revised, there may be no draft to publish, since the transition could patch the published document directly. I need to guard against calling publish when no draft exists, and also make sure validation errors block the "Move to Approved" action just like the existing workflow logic does, likely using the validation status hook.

Sanity's writes are strongly consistent, so the publish action should reliably see a patch committed just before it, which handles any race concern. For the final "Approved" stage, there's no next stage, so the plugin action just falls through to a normal publish, gated by the stage's publishing permission — which should work fine without extra customization.

Now I'm restructuring so the "Approve and publish" action appears at the stage before Approved, disabled on validation errors, wrapping the completion handler. I need to keep hook call order stable since the plugin action itself uses hooks internally, and since the dialog closes and calls onComplete before our async publish finishes, I need to guard against setting state after the component unmounts.

For the workflow definition, I'm removing the Published stage entirely and setting the Approved stage to enable publishing, updating its criteria text to explain that approving now triggers publishing. I need to update the stage exports (dropping PUBLISHED_QUIZ_STAGE, adding an export for the in-review stage), the setup script's stage-rename mapping, the README documentation, and rename the action file to reflect the new publish-on-approve behavior.

For the data side, there are published quiz documents that need migrating from "published" to "approved" status—I'm planning to dry-run the migration script first to check it against the live dataset before applying it or triggering a redeploy, and I want to confirm with the user before running anything against production data. I'm also going to check the comment in the career-quizzes API file and the README section to make sure they're consistent with the new workflow.

Clicking a stage checks task gating but only updates the draft's status without publishing, so publishing needs to be tied to the Move to Approved button instead. I'll now check the README section and web endpoint comment describing the current flow.

Agent

I should check if the Studio definition has edits compared to the file before replacing it, but a dry run won't give me a diff—I'll need to fetch the stored definition and compare key fields like stage labels, criteria, and task counts manually, which I'll handle later. For now, I'll move on to editing the workflow file.

The web endpoint already lists all published quizzes regardless of stage, so no frontend changes are needed. Next, I'll update the workflow definition by removing the Published stage, making Approved the final stage with publishing enabled, and rewording its criteria text shown in the Move to Approved confirm dialog.

Agent

I'm creating a new document action file for publishing on approval, replacing the old one, using validation status checks and toast notifications for user feedback.

Still writing publish logic...

I'm also flagging a concern: if the workflow definition hasn't loaded yet or shows no valid transition, my override could mislabel the action button as "Approve and publish" when it shouldn't apply. I need to guard against overriding the label unless the workflow result actually represents the expected transition.

There's also an edge case where a published document already sitting at in_review has no draft—there, the plugin would transition the published doc directly, which is fine and shouldn't be overridden. I also need to verify that the publish call afterward correctly operates on the existing draft, and that the plugin's synchronous onComplete handling doesn't conflict with my async publish-after-approval logic since that result gets ignored anyway.

For error messaging, I'll update the old "already published" text to something clearer like "The quiz is Approved but not live yet. Click Publish to try again," and the normal Publish action should still appear once the quiz reaches Approved. The audit trail from direct publish calls will be skipped, but that's acceptable since the transition itself already logs the status change. Now I'll write the updated file, remove the old one, and update sanity.config accordingly.

The document action wraps the plugin's In Review button, preserving its task gating, confirm dialog, and transition, and publishes the quiz via the plugin's onComplete callback—at Approved, the plugin's own Publish button already handles small fixes since that stage has publishing enabled with no next stage.

Agent

I'm noting that without a draft, the plugin patches the live published document directly — worth flagging as a constraint. Now I'll remove the old file with git rm.

Agent

Now the setup script. It moves quizzes off the removed published stage to approved, and creates new starter quizzes at Approved.

Agent

And the README section:

Agent

Now I'll type-check, lint, and build the Studio.

I should check the studio's package.json for typecheck and lint scripts to run them.

Agent

Type-check, lint and build all pass. Next a Prettier check, then a dry run of the setup script to see which quizzes would move.

TypeScript and ESLint passed without issues, so now I'm checking formatting with prettier.

Agent

I see a new quiz was added, likely Work Values Matcher, while Interest Assessment was retired rather than moved — that looks right. Before running the replace, I want to verify whether the stored Studio definition diverges from the file beyond just the published stage, so I'm planning a quick script to compare the stored definition against the new file version and print any differences in stage labels or criteria text.

The dry run shows 8 live quizzes, including the newly added 49a6d975, would move from published to approved, while retired ones stay untouched. Since applying this requires --replace (which overwrites Studio edits), I'll first compare the stored definition against the file.

Agent
Agent

The stored workflow definition matches the committed file exactly, so --replace won't overwrite any Studio edits. The code is ready; rolling it out touches production in two ways.

Since the stored definition matches HEAD exactly, --replace is safe. The key question is ordering: deploying before migrating works fine since old code still reads Published stage, but migrating first would break the old deployed Studio since its "Publish" action still references the now-removed published stage at Approved.

Best to run migrate and deploy back-to-back with minimal gap. This touches production data and workflow definitions though, so even though the user approved deploying earlier and clearly wants this working, I should check in once with concise options before proceeding given the production data impact.

Agent
Agent

Both done. I'll verify the stored definition and quiz stages.

Agent

Approved is now the last stage, and approving a quiz publishes it. When a quiz is at In Review, the Move to Approved button now reads Approve and publish. It still checks the review tasks and shows the confirm dialog, then moves the quiz to Approved and publishes it. The Published stage is gone. I've updated the stored workflow definition and redeployed the Studio.

I haven't clicked through it in the live Studio, because the Studio runs in an iframe my browser tools can't reach. Type-check, lint, Prettier and the build all pass. Try it on your next quiz that reaches In Review.

The setup script moved the 8 live quizzes, including Work Values Matcher, from Published to Approved, and they're all still live on /quiz. The two retired quizzes weren't touched. Before replacing the definition, I checked that the stored copy matched the committed file, so no Studio edits were lost.

Two edge cases:
- Publishing fails after approval: the quiz stays at Approved, a toast says it wasn't published, and the regular Publish button is there to retry. That same button handles small fixes to quizzes that are already approved.
- Status bar: clicking Approved in the status bar above the fields only changes the stage, without publishing. That's how the plugin works, so you'd need to click Publish afterwards. The README now says so.

The changes are in studio/ on main and aren't committed yet:
- New action: actions/publishOnApproveAction.ts replaces actions/publishApprovedQuizAction.ts, wired up in sanity.config.ts.
- Workflow: schemaTypes/careerQuizzes/careerQuizWorkflow.ts drops the Published stage and rewords the Approved criteria shown in the confirm dialog.
- Setup script: scripts/career-quizzes/setup.ts maps published to approved and creates new starter quizzes at Approved.
- Docs: the career quiz section of README.md describes the new flow.

I should just note that these changes are still uncommitted on main, rather than committing automatically.

Agent

That background check was the status-bar lookup I'd already used. It shows that clicking a stage in the status bar only changes the quiz's stage, after the usual task check and confirm dialog, and never publishes. That's the caveat I already noted, so nothing changes. Picking Approved there needs a separate click on Publish, while Approve and publish does both in one step. No follow-up needed.

I built this in Cursor, which DEV's Agent Sessions don't support yet, so I converted the transcript to the Claude Code format with a small script and DEV labels it that way. Cursor doesn't save tool outputs in its transcripts, so the tool calls appear without results.

Data: O*NET 31.0 Database by the U.S. Department of Labor, Employment and Training Administration (USDOL/ETA), used under the CC BY 4.0 license. O*NET® is a trademark of USDOL/ETA. USDOL/ETA has not approved, endorsed, or tested this app.

Top comments (0)