← Workflow Port
API
Tokens

Run Workflow Port from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page masks the code, finds every source construct, computes the route keys from the form, and makes the mechanical port itself - use workflow and use step functions, lifted step.run closures, sleep(), FatalError, maxRetries, the imports, then hands it all to the run as facts. The run writes the review or the rewrite; the page then holds it to its own facts and re-scans the ported code against the skill's verification checklist (wfreply.js).

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"workflow-port"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required key (task, unit, code, facts) is missing. The page always sends all five of task, unit, code, carry, facts; carry is an empty string for review.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes, chosen by task, in the order the work runs - inspect, then produce:

A missing or unknown task is answered as review. Both lanes send the same five fields. The facts are the page's scan, one finding per line with its id, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the review's decisions into carry for the rewrite lane. The review sends at most 24,000 characters of code (the middle of a longer file is cut on whole lines and marked; every finding is still in the facts); the rewrite lane takes at most 16,000.

The input

FieldRequiredWhat
taskyesreview or rewrite. If it is missing or neither, do review and set "task": "review".
unityesthe file's path, the source system, the runtime and the named app-boundary framework.
codeyesthe file as pasted (before the page's changes). A line starting "// [... N lines cut by the page" marks code the page scanned but did not send.
carrysend it (empty for review)rewrite task only: the review's decisions, one per line as id | confirmed or cleared or extra | where | fix or reason, and a route keys | ... line; may be empty.
factsyesthe page's scan: the target (runtime, app boundary, how a paused run resumes), the route keys with a "why" and an "obligation" line each, then one finding per line as id | area | severity | line | where | evidence | source | what the page did (migrated or left). "migrated" means the page made the change the finding's fix names in its own copy, which the person already has; the code field is still the original paste, so judge it as written. Lines starting "must:" apply to your reply.
retry_notenooptional; if present, your previous reply was unusable - follow it.

The rules the model applies

From the source skill, @vercel/migrating-to-workflow-sdk - the only standard the run applies:

House rules

The replies

review

Decide each of the page's findings, give the route keys their reasons, map the source constructs, add what a scan cannot see and put the steps in order. The page builds the skill's six-section migration from this reply.

  1. summary - one sentence, at most 40 words: what the port still needs, what the page already moved, and the first step.
  2. route_keys - the route keys, exactly as the facts give them (plus one resume key only when the facts leave it open).
  3. why - one string per route key, starting with the key and a colon, giving the reason from the input (e.g. "resume/internal: the app approves orders from its own server code by order id").
  4. obligations - the required code obligations of those keys, one short string each (createHook() with token order:${orderId}:approval ...); [] when there are no keys.
  5. mapping - 2 to 14 strings "source construct -> Workflow SDK construct", naming this file's own constructs (e.g. "step.run(\"load-user\") -> loadUser(), a \"use step\" function").
  6. findings - one object per page finding you confirm, in id order: id, widget (where it sits, e.g. "onboardUser"), issue (one sentence), fix (one sentence), code (the Workflow SDK lines for that piece only, at most 8; "" when fix says it all).
  7. cleared - one object per page finding that is not a problem here, with id and why (one sentence tied to the code). Every page id appears exactly once, in findings or in cleared.
  8. extra - at most 5 issues the scan cannot see, each tied to a line of the code: id (X1, X2, ...), area (entry, steps, waits, resume, children, streams, retries or boundary), line (a number), widget, issue, fix. Examples: an external write that needs getStepMetadata().stepId as its idempotency key, a value a step returns that is not serializable, a timeout whose expiry path is missing, compensation that needs a rollback stack, a step that reads a variable the workflow changes later. [] when there are none.
  9. plan - 2 to 10 short strings, the steps in order (entrypoint and steps first, then sleeps and the resume surface, then child runs, streams and retries, the app boundary last), each naming the ids it covers (e.g. "Await the approval hook with a 7d timeout (W11)"). Every confirmed id appears in a step. [] only when nothing is confirmed.
  10. questions - the open questions: every assumption you made (a resume key the form left open, a token's business key, a callback reply), one string each; [] when there are none.
  11. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "review",
  "summary": "one sentence",
  "route_keys": [ "resume/internal" ],
  "why": [ "resume/internal: one sentence" ],
  "obligations": [ "createHook() with token user:${userId}:activated in onboardUser" ],
  "mapping": [ "inngest.createFunction -> onboardUser(), a \"use workflow\" function" ],
  "findings": [ { "id": "W1", "widget": "the file", "issue": "one sentence", "fix": "one sentence", "code": "import { createHook, sleep } from \"workflow\";" } ],
  "cleared": [ { "id": "W4", "why": "one sentence" } ],
  "extra": [ { "id": "X1", "area": "steps", "line": 12, "widget": "loadUser", "issue": "one sentence", "fix": "one sentence" } ],
  "plan": [ "Await the activation hook with a 7d timeout (W11)" ],
  "questions": [ ],
  "caveats": [ ]
}

The page holds it to its own facts (check() in wfreply.js; any warning makes the reply flagged): the reply answers the task it was sent; every page finding id appears exactly once across findings and cleared, and no id the page never found; a cleared id has a reason; a confirmed id has an issue and a fix; at most five extra issues, each on a line inside the paste; the route keys equal to the page's (plus exactly one resume key when the form left it open, with the assumption in the open questions); a reason per key; 2 to 14 mapping rows; a fix order of 2 to 10 steps that names every confirmed id; a summary; no web link in the prose.

rewrite

Rewrite the whole file on the Workflow SDK, and write its app-boundary code.

  1. summary - one sentence, at most 40 words: what the port changed and what it left for the team.
  2. route_keys - as in the review (the carry's route keys line when it has one, else the facts).
  3. code - the complete ported file. Keep every workflow and step by name (the page's names for lifted steps), every import still used; change what the findings name plus every carried extra whose edit is made in this file (list it in changes; when it also relies on another module accepting new arguments, name that in a caveat; one whose edit is in another file goes in a caveat only). No source-framework primitive may remain: no import from inngest, @trigger.dev/sdk or @temporalio, no step.run, step.waitForEvent, wait.forToken, proxyActivities or defineSignal. Never shorten the file with "..." or "unchanged" - write every line. If the code holds a line starting "// [... N lines cut by the page", keep that line exactly where it is. Side effects in "use step" functions only; sleep() only in the workflow; start() / getRun() only inside steps; getWriter() / write() / close() only inside steps. Each pause through the route key's surface, with its obligations met; a timeout as Promise.race() with sleep(). Retries as stepFn.maxRetries = N, backoff as RetryableError({ retryAfter }), give-up as FatalError.
  4. boundary - the app-boundary code: the handler that starts the run with start() from workflow/api, plus the resume endpoint the route keys require (resumeHook() for resume/internal; none for resume/url/default and resume/url/manual, where webhook.url is the resume surface), plus the World and startWorkflowWorld() block for runtime/self-hosted. In the named framework's syntax; plain Request / Response otherwise. "" only when no key needs it and the facts name no app boundary.
  5. changes - one object per page finding you acted on (including the ones the page already migrated, which the rewrite keeps), in id order: id, change (one sentence naming where). Carried extra ids (X1, ...) go after the page's ids.
  6. kept - one object per page finding you did not change: id, why (one sentence - the review cleared it, or it needs a file the paste does not hold). Every page id appears exactly once, in changes or in kept; an id the carried review cleared belongs in kept.
  7. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "rewrite",
  "summary": "one sentence",
  "route_keys": [ "resume/internal" ],
  "code": "the complete ported file",
  "boundary": "the app-boundary and resume code",
  "changes": [ { "id": "W1", "change": "one sentence" } ],
  "kept": [ { "id": "W4", "why": "one sentence" } ],
  "caveats": [ ]
}

The page requires: the reply answers the task it was sent; every page finding id appears exactly once across changes and kept; the route keys are the page's; every workflow survives by name; the file is not shortened and elides nothing; app-boundary code is there when the keys need it, in the named framework; and the ported code and boundary pass the skill's verification checklist - no source primitives, sleep() only in workflows, stream writes and start() only in steps, each route key's must-emit and must-not-emit.

Worked examples

Three runs from the page's free samples (invented apps): the review of an Inngest onboarding function, the rewrite of the same file carrying that review, and the review of a Temporal order workflow moving to a self-hosted Hono app. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the code, the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.

review - An Inngest function that waits for an event

The page read it as: Needs hand work - 7 blocker, 3 major, 3 minor; findings W1 import-source, W2 client, W3 entrypoint, W4 config-dropped, W5 retries, W6 trigger, W7 step-run, W8 fatal-error, W9 step-run, W10 sleep, W11 wait-event, W12 step-run, W13 side-effect.

Input (facts is 3947 characters; the first 1398 are shown):

{
 "task": "review",
 "unit": "File: src/inngest/onboard-user.ts\nSource: Inngest\nRuntime: managed\nApp boundary: unspecified",
 "code": "import { Inngest, NonRetriableError } from \"inngest\";\nimport { db } from \"./db\";\nimport { sendEmail } from \"./email\";\n\nexport const inngest = new Inngest({ id: \"example-app\" });\n\nexport const onboardUser = inngest.createFunction(\n  { id: \"onboard-user\", retries: 4, concurrency: { limit: 10 } },\n  { event: \"user/signed.up\" },\n  async ({ event, step }) => {\n    const user = await step.run(\"load-user\", async () => {\n      const row = await db.users.find(event.data.userId);\n      if (!row) throw new NonRetriableError(\"no such user\");\n      return row;\n    });\n\n    await step.run(\"send-welcome\", () => sendEmail(user.email, \"welcome\"));\n\n    await step.sleep(\"wait-a-day\", \"1d\");\n\n    const activated = await step.waitForEvent(\"wait-for-activation\", {\n      event: \"user/activated\",\n      timeout: \"7d\",\n      match: \"data.userId\",\n    });\n\n    if (!activated) {\n      await step.run(\"send-nudge\", async () => {\n        await sendEmail(user.email, \"nudge\");\n      });\n    }\n\n    await db.audit.insert({ userId: user.id, step: \"onboarded\" });\n    return { userId: user.id, activated: !!activated };\n  }\n);\n",
 "carry": "",
 "facts": "The page's scan of 37 lines - source: Inngest; 1 workflow (onboardUser), 3 steps (loadUser, sendWelcome, sendNudge); 9 of 13 findings migrated by the page; verdict: Needs hand work.\nTarget: runtime managed; app boundary not named; resume server-side code with a deterministic business token; pauses found: 1.\nRoute keys (the page's): resume/internal.\n- why resume/internal: the app resumes the run from server-side code with a deterministic business token\n- obligation: Workflow code uses createHook() with a deterministic business token\n- obligation: The app boundary calls resumeHook() with the same token\n- obligation: No createWebhook() and no webhook.url\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- W1 | entry | blocker | line 1 | the file | import from inngest - the Workflow SDK imports from workflow and workflow/api only. | source: import { Inngest, NonRetriableError } from \"inngest\"; | migrated\n- W2 | entry | minor | line 5 | the file | new Inngest(...) sets up the Inngest client - the Workflow SDK needs no client. | source: export const inngest = new Inngest({ id: \"example-app\" }); | migrated\n- W3 | entry | blocker | line 7 | onboardUser | inngest.createFunction registers onboardUser with Inngest - the Workflow SDK runs a plain \"use workflow\" function, started with start() from the app boundary. | source: export const onboardUser =\n\n[... 2549 more characters cut from this display ...]"
}

Reply:

{
 "task": "review",
 "summary": "The page lifted the three step.run closures, the sleep, FatalError and the imports; the port still needs the activation hook with a 7d race, the audit insert as a step and a start() route. Start with the hook.",
 "route_keys": [
  "resume/internal"
 ],
 "why": [
  "resume/internal: the app resumes the onboarding run from its own server code with a deterministic business token, the user id that the user/activated match keyed on"
 ],
 "obligations": [
  "createHook() with token user:${userId}:activated in onboardUser",
  "resumeHook() with the same user:${userId}:activated token at the app boundary",
  "no createWebhook() and no webhook.url anywhere in the port"
 ],
 "mapping": [
  "inngest.createFunction(\"onboard-user\") -> onboardUser(), a \"use workflow\" function started with start()",
  "new Inngest({ id: \"example-app\" }) -> removed, the Workflow SDK needs no client",
  "event.data.userId -> the userId argument passed to start(onboardUser, [userId])",
  "step.run(\"load-user\") -> loadUser(), a \"use step\" function",
  "step.run(\"send-welcome\") -> sendWelcome(), a \"use step\" function",
  "step.run(\"send-nudge\") -> sendNudge(), a \"use step\" function",
  "step.sleep(\"wait-a-day\", \"1d\") -> sleep(\"1d\") in onboardUser",
  "step.waitForEvent(\"wait-for-activation\", timeout 7d) -> createHook() raced against sleep(\"7d\")",
  "NonRetriableError -> FatalError",
  "retries: 4 -> maxRetries = 4 on each step function",
  "db.audit.insert -> writeAudit(), a \"use step\" function",
  "the user/signed.up event trigger -> app-boundary start()"
 ],
 "findings": [
  {
   "id": "W1",
   "widget": "the file",
   "issue": "The file imports Inngest and NonRetriableError from inngest, which the Workflow SDK does not use.",
   "fix": "Import createHook, sleep and FatalError from workflow and drop the inngest import.",
   "code": "import { createHook, sleep, FatalError } from \"workflow\";"
  },
  {
   "id": "W2",
   "widget": "the file",
   "issue": "The exported Inngest client has no role once the function is a plain workflow.",
   "fix": "Delete the new Inngest(...) line and its export.",
   "code": ""
  },
  {
   "id": "W3",
   "widget": "onboardUser",
   "issue": "inngest.createFunction registers onboardUser with Inngest instead of exposing a workflow function.",
   "fix": "Make onboardUser an async function taking userId with the \"use workflow\" directive, started by start() at the app boundary.",
   "code": "export async function onboardUser(userId: string) {\n  \"use workflow\";"
  },
  {
   "id": "W4",
   "widget": "onboardUser",
   "issue": "concurrency: { limit: 10 } caps parallel runs in Inngest, and the Workflow SDK has no function-level equivalent.",
   "fix": "Remove the option from the port and, if the cap still matters, enforce it where the app calls start().",
   "code": ""
  },
  {
   "id": "W5",
   "widget": "onboardUser",
   "issue": "retries: 4 covered every step at once, but the Workflow SDK counts retries per step.",
   "fix": "Set maxRetries = 4 on loadUser, sendWelcome, sendNudge and writeAudit.",
   "code": "loadUser.maxRetries = 4;\nsendWelcome.maxRetries = 4;\nsendNudge.maxRetries = 4;\nwriteAudit.maxRetries = 4;"
  },
  {
   "id": "W6",
   "widget": "onboardUser",
   "issue": "The run starts from the user/signed.up event routed through Inngest, which no longer exists.",
   "fix": "Start the run from the app boundary with start(onboardUser, [userId]) where the signup is handled.",
   "code": "const run = await start(onboardUser, [userId]);"
  },
  {
   "id": "W7",
   "widget": "onboardUser",
   "issue": "The load-user closure is an inline Inngest step.",
   "fix": "Call loadUser(userId), a named \"use step\" function holding the db.users.find lookup.",
   "code": "async function loadUser(userId: string) {\n  \"use step\";\n  const row = await db.users.find(userId);\n  if (!row) throw new FatalError(\"no such user\");\n  return row;\n}"
  },
  {
   "id": "W8",
   "widget": "loadUser",
   "issue": "NonRetriableError stops Inngest retries but is not a Workflow SDK class.",
   "fix": "Throw FatalError(\"no such user\") inside loadUser.",
   "code": "if (!row) throw new FatalError(\"no such user\");"
  },
  {
   "id": "W9",
   "widget": "onboardUser",
   "issue": "The send-welcome closure is an inline Inngest step.",
   "fix": "Call sendWelcome(user.email), a named \"use step\" function wrapping sendEmail.",
   "code": "async function sendWelcome(email: string) {\n  \"use step\";\n  await sendEmail(email, \"welcome\");\n}"
  },
  {
   "id": "W10",
   "widget": "onboardUser",
   "issue": "step.sleep(\"wait-a-day\", \"1d\") is an Inngest timer.",
   "fix": "Await sleep(\"1d\") directly in the workflow body.",
   "code": "await sleep(\"1d\");"
  },
  {
   "id": "W11",
   "widget": "onboardUser",
   "issue": "step.waitForEvent pauses on a user/activated match with a 7d timeout, which has no Workflow SDK form.",
   "fix": "Create a hook keyed by the user id and race it against sleep(\"7d\"), treating the sleep branch as not activated.",
   "code": "using activation = createHook<{ activated: true }>({ token: \"user:\" + userId + \":activated\" });\nconst result = await Promise.race([\n  activation.then((payload) => ({ kind: \"activated\" as const, payload })),\n  sleep(\"7d\").then(() => ({ kind: \"timeout\" as const })),\n]);\nconst activated = result.kind === \"activated\";"
  },
  {
   "id": "W12",
   "widget": "onboardUser",
   "issue": "The send-nudge closure is an inline Inngest step.",
   "fix": "Call sendNudge(user.email), a named \"use step\" function, when the race timed out.",
   "code": "async function sendNudge(email: string) {\n  \"use step\";\n  await sendEmail(email, \"nudge\");\n}"
  },
  {
   "id": "W13",
   "widget": "onboardUser",
   "issue": "db.audit.insert runs in workflow context, so a replay would repeat the write outside any step.",
   "fix": "Move the insert into writeAudit(userId), a \"use step\" function the workflow awaits.",
   "code": "async function writeAudit(userId: string) {\n  \"use step\";\n  await db.audit.insert({ userId, step: \"onboarded\" });\n}"
  }
 ],
 "cleared": [],
 "extra": [
  {
   "id": "X1",
   "area": "steps",
   "line": 14,
   "widget": "loadUser",
   "issue": "loadUser returns the raw db row to the workflow, and a step return value must be serializable.",
   "fix": "Return only the plain fields the workflow reads, the id and the email."
  },
  {
   "id": "X2",
   "area": "steps",
   "line": 17,
   "widget": "sendWelcome",
   "issue": "sendEmail is an external write that a step retry can send twice.",
   "fix": "Pass getStepMetadata().stepId to the email send as its idempotency key, which needs the email module to accept one."
  },
  {
   "id": "X3",
   "area": "resume",
   "line": 24,
   "widget": "onboardUser",
   "issue": "match: \"data.userId\" bound the event to the user, and the hook only stays bound if its token holds the same id.",
   "fix": "Build the token from the userId argument and resume with that same token from the activation code."
  }
 ],
 "plan": [
  "Drop the inngest import and client and declare onboardUser as a \"use workflow\" function taking userId (W1, W2, W3)",
  "Remove the concurrency option and set maxRetries = 4 on each step (W4, W5)",
  "Lift load-user, send-welcome and send-nudge into named steps, with FatalError in loadUser and a plain return value (W7, W8, W9, W12, X1)",
  "Move the audit insert into the writeAudit step (W13)",
  "Replace step.sleep with sleep(\"1d\") (W10)",
  "Await the activation hook keyed by the user id with a 7d timeout (W11, X3)",
  "Give the email sends a stepId idempotency key (X2)",
  "Start the run with start() and resume it with resumeHook() at the app boundary (W6)"
 ],
 "questions": [
  "The token user:${userId}:activated assumes the user id alone identifies the activation, as the data.userId match did.",
  "The hook payload is assumed to carry nothing the workflow needs beyond the fact of activation."
 ],
 "caveats": [
  "The code that used to send user/activated and user/signed.up must now call resumeHook() and start(); those files are not in the paste.",
  "The stepId idempotency key needs a change in the email module, which the paste does not hold.",
  "No framework is named, so the app-boundary code uses plain Request / Response."
 ]
}

What step 5 prints for it: W1: Import createHook, sleep and FatalError from workflow and drop the inngest import. | W2: Delete the new Inngest(...) line and its export. | W3: Make onboardUser an async function taking userId with the "use workflow" directive, started by start() at the app boundary. | W4: Remove the option from the port and, if the cap still matters, enforce it where the app calls start(). | W5: Set maxRetries = 4 on loadUser, sendWelcome, sendNudge and writeAudit. | W6: Start the run from the app boundary with start(onboardUser, [userId]) where the signup is handled. | W7: Call loadUser(userId), a named "use step" function holding the db.users.find lookup. | W8: Throw FatalError("no such user") inside loadUser. | W9: Call sendWelcome(user.email), a named "use step" function wrapping sendEmail. | W10: Await sleep("1d") directly in the workflow body. | W11: Create a hook keyed by the user id and race it against sleep("7d"), treating the sleep branch as not activated. | W12: Call sendNudge(user.email), a named "use step" function, when the race timed out. | W13: Move the insert into writeAudit(userId), a "use step" function the workflow awaits.

The page's check: kept - X2 repeats what the page already found on line 17.

rewrite - An Inngest function that waits for an event

The input carries the saved review (R.laneForm fills carry from the review reply), as the page's handoff does. The page ran the skill's verification checklist on the ported code and its app boundary: 0 failing of the 13 findings' file.

Input (facts is 3973 characters; the first 1398 are shown; carry is 1889 characters; the first 1397 are shown):

{
 "task": "rewrite",
 "unit": "File: src/inngest/onboard-user.ts\nSource: Inngest\nRuntime: managed\nApp boundary: unspecified",
 "code": "import { Inngest, NonRetriableError } from \"inngest\";\nimport { db } from \"./db\";\nimport { sendEmail } from \"./email\";\n\nexport const inngest = new Inngest({ id: \"example-app\" });\n\nexport const onboardUser = inngest.createFunction(\n  { id: \"onboard-user\", retries: 4, concurrency: { limit: 10 } },\n  { event: \"user/signed.up\" },\n  async ({ event, step }) => {\n    const user = await step.run(\"load-user\", async () => {\n      const row = await db.users.find(event.data.userId);\n      if (!row) throw new NonRetriableError(\"no such user\");\n      return row;\n    });\n\n    await step.run(\"send-welcome\", () => sendEmail(user.email, \"welcome\"));\n\n    await step.sleep(\"wait-a-day\", \"1d\");\n\n    const activated = await step.waitForEvent(\"wait-for-activation\", {\n      event: \"user/activated\",\n      timeout: \"7d\",\n      match: \"data.userId\",\n    });\n\n    if (!activated) {\n      await step.run(\"send-nudge\", async () => {\n        await sendEmail(user.email, \"nudge\");\n      });\n    }\n\n    await db.audit.insert({ userId: user.id, step: \"onboarded\" });\n    return { userId: user.id, activated: !!activated };\n  }\n);\n",
 "carry": "route keys | resume/internal\nW1 | confirmed | the file | Import createHook, sleep and FatalError from workflow and drop the inngest import.\nW2 | confirmed | the file | Delete the new Inngest(...) line and its export.\nW3 | confirmed | onboardUser | Make onboardUser an async function taking userId with the \"use workflow\" directive, started by start() at the app boundary.\nW4 | confirmed | onboardUser | Remove the option from the port and, if the cap still matters, enforce it where the app calls start().\nW5 | confirmed | onboardUser | Set maxRetries = 4 on loadUser, sendWelcome, sendNudge and writeAudit.\nW6 | confirmed | onboardUser | Start the run from the app boundary with start(onboardUser, [userId]) where the signup is handled.\nW7 | confirmed | onboardUser | Call loadUser(userId), a named \"use step\" function holding the db.users.find lookup.\nW8 | confirmed | loadUser | Throw FatalError(\"no such user\") inside loadUser.\nW9 | confirmed | onboardUser | Call sendWelcome(user.email), a named \"use step\" function wrapping sendEmail.\nW10 | confirmed | onboardUser | Await sleep(\"1d\") directly in the workflow body.\nW11 | confirmed | onboardUser | Create a hook keyed by the user id and race it against sleep(\"7d\"), treating the sleep branch as not activated.\nW12 | confirmed | onboardUser | Call sendNudge(user.email), a named \"use step\" function, when the race timed out.\nW13 | confirmed |\n\n[... 492 more characters cut from this display ...]",
 "facts": "The page's scan of 37 lines - source: Inngest; 1 workflow (onboardUser), 3 steps (loadUser, sendWelcome, sendNudge); 9 of 13 findings migrated by the page; verdict: Needs hand work.\nTarget: runtime managed; app boundary not named; resume server-side code with a deterministic business token; pauses found: 1.\nRoute keys (the page's): resume/internal.\n- why resume/internal: the app resumes the run from server-side code with a deterministic business token\n- obligation: Workflow code uses createHook() with a deterministic business token\n- obligation: The app boundary calls resumeHook() with the same token\n- obligation: No createWebhook() and no webhook.url\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- W1 | entry | blocker | line 1 | the file | import from inngest - the Workflow SDK imports from workflow and workflow/api only. | source: import { Inngest, NonRetriableError } from \"inngest\"; | migrated\n- W2 | entry | minor | line 5 | the file | new Inngest(...) sets up the Inngest client - the Workflow SDK needs no client. | source: export const inngest = new Inngest({ id: \"example-app\" }); | migrated\n- W3 | entry | blocker | line 7 | onboardUser | inngest.createFunction registers onboardUser with Inngest - the Workflow SDK runs a plain \"use workflow\" function, started with start() from the app boundary. | source: export const onboardUser =\n\n[... 2575 more characters cut from this display ...]"
}

Reply (long strings shortened for display - see the cuts below):

{
 "task": "rewrite",
 "summary": "The port turns onboardUser into a workflow with four named steps, sleep(\"1d\") and an activation hook raced against 7d, and leaves the email idempotency key and the signup and activation callers for the team.",
 "route_keys": [
  "resume/internal"
 ],
 "code": "import { createHook, sleep, FatalError } from \"workflow\";\nimport { db } from \"./db\";\nimport { sendEmail } from \"./email\";\n\ntype ActivationPayload = { userId: string };\n\nexport async function onboardUser(userId: string) {\n  \"use workflow\";\n\n  const user = await loadUser(userId);\n\n  await sendWelcome(user.email); [... 1099 more characters cut from this display]",
 "boundary": "import { start, resumeHook } from \"workflow/api\";\nimport { onboardUser } from \"../inngest/onboard-user\";\n\n// Signup handler: replaces the user/signed.up event trigger.\nexport async function handleSignup(request: Request): Promise<Response> {\n  const body = (await request.json()) as { userId: string };\n  const run = [... 416 more characters cut from this display]",
 "changes": [
  {
   "id": "W1",
   "change": "The file imports createHook, sleep and FatalError from workflow and no longer imports inngest."
  },
  {
   "id": "W2",
   "change": "The new Inngest client and its export are gone from the file."
  },
  {
   "id": "W3",
   "change": "onboardUser is now an exported async function taking userId with the \"use workflow\" directive."
  },
  {
   "id": "W4",
   "change": "The concurrency option is dropped from onboardUser, with no function-level replacement."
  },
  {
   "id": "W5",
   "change": "loadUser, sendWelcome, sendNudge and writeAudit each set maxRetries = 4."
  },
  {
   "id": "W6",
   "change": "handleSignup at the app boundary starts the run with start(onboardUser, [userId]) in place of the user/signed.up trigger."
  },
  {
   "id": "W7",
   "change": "The load-user closure is the loadUser step, which looks the user up by userId."
  },
  {
   "id": "W8",
   "change": "loadUser throws FatalError(\"no such user\") when no row is found."
  },
  {
   "id": "W9",
   "change": "The send-welcome closure is the sendWelcome step taking the email."
  },
  {
   "id": "W10",
   "change": "onboardUser awaits sleep(\"1d\") in place of step.sleep."
  },
  {
   "id": "W11",
   "change": "onboardUser creates the hook with token user:userId:activated and races it against sleep(\"7d\"), while handleActivation calls resumeHook() with the same token."
  },
  {
   "id": "W12",
   "change": "The send-nudge closure is the sendNudge step, run when the race times out."
  },
  {
   "id": "W13",
   "change": "The audit insert runs in the writeAudit step instead of workflow context."
  },
  {
   "id": "X1",
   "change": "loadUser returns only the id and email fields so its return value stays serializable."
  },
  {
   "id": "X3",
   "change": "The hook token is built from the userId argument, and handleActivation resumes with that same token."
  }
 ],
 "kept": [],
 "caveats": [
  "X2 is not in the code: passing getStepMetadata().stepId as an idempotency key needs the email module, which the paste does not hold, to accept one.",
  "The code that sent user/signed.up and user/activated must call handleSignup and handleActivation instead; those files are not in the paste.",
  "The hook is created after the 1d sleep, as waitForEvent was, so an activation during the first day is not seen."
 ]
}

Cut from this display only (the saved reply and the check use the full text): code (1411 characters, 312 shown); boundary (732 characters, 316 shown).

What step 5 prints for it: W1: The file imports createHook, sleep and FatalError from workflow and no longer imports inngest. | W2: The new Inngest client and its export are gone from the file. | W3: onboardUser is now an exported async function taking userId with the "use workflow" directive. | W4: The concurrency option is dropped from onboardUser, with no function-level replacement. | W5: loadUser, sendWelcome, sendNudge and writeAudit each set maxRetries = 4. | W6: handleSignup at the app boundary starts the run with start(onboardUser, [userId]) in place of the user/signed.up trigger. | W7: The load-user closure is the loadUser step, which looks the user up by userId. | W8: loadUser throws FatalError("no such user") when no row is found. | W9: The send-welcome closure is the sendWelcome step taking the email. | W10: onboardUser awaits sleep("1d") in place of step.sleep. | W11: onboardUser creates the hook with token user:userId:activated and races it against sleep("7d"), while handleActivation calls resumeHook() with the same token. | W12: The send-nudge closure is the sendNudge step, run when the race times out. | W13: The audit insert runs in the writeAudit step instead of workflow context. | X1: loadUser returns only the id and email fields so its return value stays serializable. | X3: The hook token is built from the userId argument, and handleActivation resumes with that same token.

The page's check: kept - nothing to flag

review - A Temporal workflow with a Signal, self-hosted

The page read it as: Needs hand work - 3 blocker, 7 major, 0 minor; findings W1 import-left, W2 backoff, W3 proxy-activities, W4 activity-timeout, W5 signal, W6 entrypoint, W7 handler, W8 condition, W9 fatal-error, W10 sleep.

Input (facts is 3831 characters; the first 1391 are shown):

{
 "task": "review",
 "unit": "File: src/workflows/order.ts\nSource: Temporal\nRuntime: self-hosted\nApp boundary: Hono",
 "code": "import { proxyActivities, sleep, defineSignal, setHandler, condition, ApplicationFailure } from \"@temporalio/workflow\";\nimport type * as activities from \"./activities\";\n\nconst { reserveStock, chargeCard, shipOrder } = proxyActivities<typeof activities>({\n  startToCloseTimeout: \"1 minute\",\n  retry: { maximumAttempts: 5, initialInterval: \"2s\" },\n});\n\nexport const approveSignal = defineSignal<[boolean]>(\"approve\");\n\nexport async function orderWorkflow(orderId: string, amount: number): Promise<string> {\n  let approved: boolean | undefined;\n  setHandler(approveSignal, (value) => { approved = value; });\n\n  await reserveStock(orderId);\n  if (amount > 1000) {\n    const ok = await condition(() => approved !== undefined, \"3 days\");\n    if (!ok || !approved) throw ApplicationFailure.nonRetryable(\"not approved\");\n  }\n  await chargeCard(orderId, amount);\n  await sleep(\"30 seconds\");\n  await shipOrder(orderId);\n  return \"shipped\";\n}\n",
 "carry": "",
 "facts": "The page's scan of 25 lines - source: Temporal; 1 workflow (orderWorkflow), 0 steps (none); 4 of 10 findings migrated by the page; verdict: Needs hand work.\nTarget: runtime self-hosted; app boundary Hono; resume server-side code with a deterministic business token; pauses found: 1.\nRoute keys (the page's): resume/internal, runtime/self-hosted, boundary/named-framework.\n- why resume/internal: the app resumes the run from server-side code with a deterministic business token\n- why runtime/self-hosted: the target is self-hosted\n- why boundary/named-framework: the app boundary is Hono\n- obligation: Workflow code uses createHook() with a deterministic business token\n- obligation: The app boundary calls resumeHook() with the same token\n- obligation: No createWebhook() and no webhook.url\n- obligation: interface World extends Queue, Streamer, Storage { start?(): Promise<void>; }\n- obligation: startWorkflowWorld(): Promise<void> calling await getWorld().start?.()\n- obligation: The note that the workflow and step code stay the same while the app still needs a custom World\n- obligation: Every app-boundary snippet in the named framework's syntax, no plain Request / Response handlers\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- W1 | entry | major | line 1 | the file | import from @temporalio/workflow still serves defineSignal, setHandler,\n\n[... 2440 more characters cut from this display ...]"
}

Reply (long strings shortened for display - see the cuts below):

{
 "task": "review",
 "summary": "The page turned orderWorkflow into a workflow, the Activities into steps, the 30-second sleep and the nonRetryable failure into FatalError; the port still needs the approval hook, the retry and timeout rules, a World and Hono routes. Start with the hook.",
 "route_keys": [
  "resume/internal",
  "runtime/self-hosted",
  "boundary/named-framework"
 ],
 "why": [
  "resume/internal: the app approves large orders from its own server code, addressed by the order id as a deterministic business token",
  "runtime/self-hosted: the order workflow runs on self-hosted infrastructure, not a managed platform",
  "boundary/named-framework: the app boundary is Hono, so the start and approval routes use Hono handlers"
 ],
 "obligations": [
  "createHook() with token order:${orderId}:approval in orderWorkflow",
  "resumeHook() with the same order:${orderId}:approval token in a Hono route",
  "no createWebhook() and no webhook.url",
  "interface World extends Queue, Streamer, Storage { start?(): Promise<void>; }",
  "startWorkflowWorld(): Promise<void> calling await getWorld().start?.() on server startup",
  "the note that workflow and step code stay as they are while the self-hosted app supplies a custom World",
  "every app-boundary snippet in Hono syntax, no plain Request / Response handlers"
 ],
 "mapping": [
  "orderWorkflow (Temporal workflow) -> orderWorkflow(), a \"use workflow\" function started with start()",
  "proxyActivities<typeof activities> -> reserveStock, chargeCard and shipOrder imported directly as \"use step\" functions",
  "retry { maximumAttempts: 5 } -> maxRetries = 5 on each Activity step",
  "retry { initialInterval: \"2s\" } -> RetryableError with retryAfter \"2s\" inside the Activities",
  "startToCloseTimeout: \"1 minute\" -> Promise.race() of each step call with sleep(\"1m\")",
  "defineSignal approveSignal -> createHook<{ approved: boolean }>() with token order:${orderId}:approval",
  "setHandler(approveSignal) and condition(..., \"3 days\") -> the hook raced against sleep(\"3d\")",
  "ApplicationFailure.nonRetryable -> FatalError",
  "Temporal sleep(\"30 seconds\") -> sleep(\"30s\") from workflow",
  "Temporal worker and task queue -> a custom World plus startWorkflowWorld()"
 ],
 "findings": [
  {
   "id": "W1",
   "widget": "the file",
   "issue": "The @temporalio/workflow import still supplies defineSignal, setHandler and condition, so the file stays bound to Temporal.",
   "fix": "Import createHook, sleep and FatalError from workflow and the Activities from ./activities, and drop the Temporal import.",
   "code": "import { createHook, sleep, FatalError } from \"workflow\";\nimport { reserveStock, chargeCard, shipOrder } from \"./activities\";"
  },
  {
   "id": "W2",
   "widget": "the file",
   "issue": "The retry policy sets maximumAttempts and initialInterval, which the Workflow SDK has no options object for.",
   "fix": "Set maxRetries = 5 on each Activity step and have the Activities throw RetryableError with retryAfter \"2s\" on a transient failure.",
   "code": "reserveStock.maxRetries = 5;\nchargeCard.maxRetries = 5;\nshipOrder.maxRetries = 5;"
  },
  {
   "id": "W3",
   "widget": "the file",
   "issue": "proxyActivities routes reserveStock, chargeCard and shipOrder through a task queue.",
   "fix": "Remove proxyActivities and call the Activities directly, each carrying the \"use step\" directive in the Activities module.",
   "code": ""
  },
  {
   "id": "W4",
   "widget": "the file",
   "issue": "startToCloseTimeout: \"1 minute\" bounded each Activity attempt, and the Workflow SDK has no timeout option on a step.",
   "fix": "Race each Activity call against sleep(\"1m\") in the workflow and throw FatalError when the sleep wins.",
   "code": "async function withinMinute<T>(call: Promise<T>, name: string): Promise<T> {\n  const result = await Promise.race([\n    call.then((value) => ({ kind: \"done\" as const, value })),\n    sleep(\"1m\").then(() => ({ kind: \"timeout\" as const })),\n  ]);\n  if (result.kind === \"timeout\") throw new FatalError(name + \" timed out\"); [... 25 more characters cut from this display]"
  },
  {
   "id": "W5",
   "widget": "the file",
   "issue": "defineSignal declares the approve Signal, which the Workflow SDK replaces with a hook.",
   "fix": "Delete approveSignal and create a hook typed { approved: boolean } with the order token inside orderWorkflow.",
   "code": "using approval = createHook<{ approved: boolean }>({ token: \"order:\" + orderId + \":approval\" });"
  },
  {
   "id": "W6",
   "widget": "orderWorkflow",
   "issue": "orderWorkflow is a Temporal workflow definition run by a worker.",
   "fix": "Keep the signature and add the \"use workflow\" directive, started by start() from the Hono route.",
   "code": "export async function orderWorkflow(orderId: string, amount: number): Promise<string> {\n  \"use workflow\";"
  },
  {
   "id": "W7",
   "widget": "orderWorkflow",
   "issue": "setHandler writes the Signal value into a mutable approved variable.",
   "fix": "Remove the handler and the variable, reading the decision from the hook payload instead.",
   "code": ""
  },
  {
   "id": "W8",
   "widget": "orderWorkflow",
   "issue": "condition(..., \"3 days\") waits for the Signal with a deadline through Temporal plumbing.",
   "fix": "Race the hook against sleep(\"3d\") and fail with FatalError on timeout or rejection.",
   "code": "const result = await Promise.race([\n  approval.then((payload) => ({ kind: \"approval\" as const, payload })),\n  sleep(\"3d\").then(() => ({ kind: \"timeout\" as const })),\n]);\nif (result.kind === \"timeout\" || !result.payload.approved) throw new FatalError(\"not approved\");"
  },
  {
   "id": "W9",
   "widget": "orderWorkflow",
   "issue": "ApplicationFailure.nonRetryable is Temporal's give-up error.",
   "fix": "Throw FatalError(\"not approved\") instead.",
   "code": "throw new FatalError(\"not approved\");"
  },
  {
   "id": "W10",
   "widget": "orderWorkflow",
   "issue": "Temporal sleep(\"30 seconds\") comes from @temporalio/workflow.",
   "fix": "Await sleep(\"30s\") imported from workflow.",
   "code": "await sleep(\"30s\");"
  }
 ],
 "cleared": [],
 "extra": [
  {
   "id": "X1",
   "area": "steps",
   "line": 20,
   "widget": "chargeCard",
   "issue": "chargeCard is an external payment write that a step retry can repeat.",
   "fix": "Use getStepMetadata().stepId inside chargeCard as the payment idempotency key."
  },
  {
   "id": "X2",
   "area": "steps",
   "line": 18,
   "widget": "orderWorkflow",
   "issue": "A rejected or expired approval fails the run after reserveStock already held stock, with nothing to undo it.",
   "fix": "Call a compensating release step before throwing, once the Activities module offers one."
  }
 ],
 "plan": [
  "Drop the Temporal import and proxyActivities and mark orderWorkflow and the Activities with their directives (W1, W3, W6)",
  "Set maxRetries = 5 and a 2s RetryableError delay on the Activity steps (W2)",
  "Race each Activity call against sleep(\"1m\") (W4)",
  "Replace the Signal, handler and condition with an approval hook raced against sleep(\"3d\"), failing with FatalError (W5, W7, W8, W9)",
  "Use sleep(\"30s\") from workflow (W10)",
  "Key chargeCard by its stepId and decide how a failed approval releases stock (X1, X2)",
  "Add the Hono start and resumeHook() routes plus the World and startWorkflowWorld() bootstrap"
 ],
 "questions": [
  "The token order:${orderId}:approval assumes one approval decision per order.",
  "Whether a rejected or expired approval should release the reserved stock is left to the team, since no release Activity exists in the paste."
 ],
 "caveats": [
  "The \"use step\" directives, the RetryableError delay and the stepId key all live in the Activities module, which the paste does not hold.",
  "The 1m race bounds a call with all its retries, not each attempt; AbortSignal.timeout() inside the Activities would bound one attempt.",
  "The Temporal worker and task queue setup lives elsewhere and should be removed."
 ]
}

Cut from this display only (the saved reply and the check use the full text): findings[3].code (343 characters, 318 shown).

What step 5 prints for it: W1: Import createHook, sleep and FatalError from workflow and the Activities from ./activities, and drop the Temporal import. | W2: Set maxRetries = 5 on each Activity step and have the Activities throw RetryableError with retryAfter "2s" on a transient failure. | W3: Remove proxyActivities and call the Activities directly, each carrying the "use step" directive in the Activities module. | W4: Race each Activity call against sleep("1m") in the workflow and throw FatalError when the sleep wins. | W5: Delete approveSignal and create a hook typed { approved: boolean } with the order token inside orderWorkflow. | W6: Keep the signature and add the "use workflow" directive, started by start() from the Hono route. | W7: Remove the handler and the variable, reading the decision from the hook payload instead. | W8: Race the hook against sleep("3d") and fail with FatalError on timeout or rejection. | W9: Throw FatalError("not approved") instead. | W10: Await sleep("30s") imported from workflow.

The page's check: kept - nothing to flag

Build the input yourself

Load the engine in node (global.window = global; require("./wfkit.js"); require("./wfreply.js")), then:

const K = WfKit, R = WfReply;
const f = K.form({ name: "src/jobs/nightly.ts", source: "auto", runtime: "managed", boundary: "unspecified", resume: "unspecified", carry: "",
  code: "import { Inngest } from \"inngest\";\nexport const inngest = new Inngest({ id: \"app\" });\nexport const nightly = inngest.createFunction({ id: \"nightly\" }, { cron: \"0 3 * * *\" }, async ({ step }) => {\n  await step.sleep(\"pause\", \"1h\");\n});" });
// f.findings (each with .auto when the page ported it), f.scan.migrated (the ported copy), f.routes (keys, why, obligations)
// K.findingsCsv(f), K.itemsCsv(f), K.checklistMd(f) - the free exports
const b = K.buildInput(f, "review");                         // or "rewrite" (R.laneForm(form, "rewrite", review) fills carry)
const wire = K.toWire(b.input);                              // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "review");
// R.check(reply, b)                                         // {status: kept or flags, notes}; R.md(title, b, reply) is the six-section migration

Get a token for the steps below from /tokens.html.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://workflow-port.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"workflow-port"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "review", "unit": "File: src/inngest/onboard-user.ts\nSource: Inngest\nRuntime: managed\nApp boundary: unspecified", "code": "import { Inngest, NonRetriableError } from \"inngest\";\n...", "carry": "", "facts": "The page's scan of 38 lines - source: Inngest ...\nRoute keys (the page's): resume/internal.\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- W1 | entry | blocker | line 1 | ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses workflow-port:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A review reply carries summary, route_keys, why, obligations, mapping, findings, cleared, extra, plan, questions and caveats; a rewrite reply carries summary, route_keys, code, boundary, changes, kept and caveats. The code below prints the gist of either: each confirmed finding and its fix, or each change.

KEY="workflow-port:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "rewrite" then (.changes[] | "\(.id): \(.change)") 
       else (.findings[] | "\(.id): \(.fix)") end' reply.json

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: workflow-port:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json