← Annalward / API
Tokens

Drive Annalward from your own code

The page reads pasted Temporal Python (temporalio) in the browser for free — one or more files joined into one paste, each introduced by a # file: <path> line, plus pyproject.toml for the temporalio and Python versions — with its line numbers. It lexes strings and comments out first, rebuilds every block from indentation, inventories every @workflow.defn class and its run, signal, query and update handlers, every @activity.defn function and whether it heartbeats, every activity call made from workflow code with its timeouts and retry policy, every compensation list and the order it is replayed in, and every Worker and what it registers, and runs 21 deterministic checks drawn from durable-execution practice (catalogue ids TW-01..TW-21, plus TW-22 for context): the system clock, randomness, network, file or database I/O, time.sleep, threads and executors in workflow code, module state changed from a workflow, the stdlib logger in workflow code, an activity call with no timeout, with a numeric timeout or with more than one positional argument, retries with no bound, a heartbeat_timeout nothing heartbeats for, a blocking call in an async def activity, a long activity loop with no heartbeat, compensations replayed in the order they were added or registered after their step, a swallowed activity failure or cancellation, a workflow loop with no continue-as-new, a query that changes state, a workflow or activity no Worker registers, and sync activities on a Worker with no activity_executor. Nothing is imported or run, no Temporal server is contacted, and it is not the replayer: every flag is a reading of the text. Its two paid steps are available over HTTP. You send one JSON object with task set to "audit" or "fix", the paste as code and the page’s check as a facts string, and the model replies with one JSON object: an audit that rates the four areas, answers every flag and gives exact before/after edits per finding, or the whole fixed file(s) with a list of every change tied to the page’s flags.

Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends Content-Type: application/json and, once you have a token, Authorization: Bearer <token>. The paths below (/guest, /me, /estimate, /run, /jobs/{job_id}, /run-stream) are relative to it, exactly as sdk.js calls them. A plain-text summary of the app for agents is at /llms.txt. Both lanes run on the gpt-terra model with a 10% publisher markup. A run needs a signed-in (user) token and is metered: the platform places a hold on your credits when the run starts and then charges only what the run used. /estimate is free.

The response envelope

Responses are JSON of the form {"ok": ..., "data": ..., "error": {...}}. The page’s SDK (sdk.js) decides success from the HTTP status, not from ok: on a 2xx it returns data; otherwise it raises an error whose message is error.message (or the HTTP status text when the body carries none), with status, code (error.code) and details (error.details) attached. A body that is not JSON is treated as {}.

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

Error codes

Branch on the HTTP status and error.code, show error.message. The page handles 401 and 402 by name; the rest reach it as a generic failure (“The run failed: ...”).

HTTPerror.codeWhat it means / what to do
400VALIDATION_ERRORThe body is not a JSON object, a required field (task, code, facts) is missing, or a declared field has the wrong type. Every field is a string; sending facts or audit_handoff as an object is the usual cause.
401UNAUTHORIZEDNo token, or one that expired or was revoked. The page forgets the signed-in user and says “Your session expired - sign in again.” Get a new token.
402INSUFFICIENT_CREDITSThe balance cannot cover the run. The page says “Not enough credits for this run.” with a top-up link, and tries never to submit into a 402: it disables the run button when the /me balance is below the estimate’s min_credits.
404NOT_FOUNDA wrong path, or a job id that does not belong to this token.
409CONFLICTAn Idempotency-Key replayed with a different body. Send the original body, or use a new key (the page bumps its :a<attempt> suffix for a retry).
429RATE_LIMITEDToo many requests. Back off with a delay; do not tight-loop, and never resend a metered run without its original Idempotency-Key.

Failures that do not arrive as an HTTP status:

WhatWhat happens
event: error on /run-streamThe SDK raises an error carrying the event’s message, code and job_id.
Job status: "failed"Polling stops; the job’s error field says why.
Polling past 180 sThe SDK’s own client-side limit (waitForJob, 1 s interval, 180 000 ms default): it throws “job timed out”. The job itself is not cancelled.

The task field: two lanes

task routes the run, and it takes exactly two values: "audit" and "fix". Each lane has its own reply contract, and a reply never mixes them. The skill’s instructions tell the model to do the audit lane when task is missing or anything else (and the page’s own Tempkit.buildInput turns any lane that is not "fix" into "audit"), so a typo silently gets you an audit: always send one of the two values.

taskWhat it doesverdictUse it when
"audit"Reviews the code the way a senior engineer reviews Temporal workflows before the first execution starts: says what the workflows orchestrate and which activities touch the world, rates the four areas (determinism, activities, compensation, operations) as sound, fragile or broken, confirms or dismisses every page flag, adds what text rules cannot see (a side effect that is not idempotent under retries, a permanent failure that is retried, a workflow that reports success after compensating), and gives the verbatim evidence and exact before/after edits for each finding, listed in the order to apply them — what cannot run or replay first, then lost failures, compensations and cancellations, then history and registration, then hygiene — with a plan that ends in a replay test (temporalio.worker.Replayer) and the failure paths run in WorkflowEnvironment.start_time_skipping().blocked | needs_work | readyBefore shipping: will it replay, will the SDK accept every activity call, are retries bounded and side effects idempotent, what does a failure or a cancel leave behind, and what should change first.
"fix"Writes the fixed version of the pasted code: the whole of every Python file it changes (and at most one new file), with workflow code made deterministic (workflow.now(), workflow.random(), await asyncio.sleep(...), I/O moved into an activity), every activity call given a timedelta timeout, args=[...] and a bounded retry policy, compensations registered before their step and run in reverse, failures and cancellation re-raised and the Worker able to start — plus a changes list tied to the page flags, the verify steps (a replay with temporalio.worker.Replayer and the failure test to write), and what it left open. No public name or signature changes, no def turned into async def or back, and no signal, query, update or task queue renamed, unless the note asks for it.fixed | partialWhen you want the code changed, not a list of findings: a worker you already know is broken, or what is left after an audit.

The page chains the two: after an audit, “Apply every edit and fix the rest” puts every audit edit into the paste, switches to the fix lane on that edited paste and adds an audit_handoff string (below) with the audit’s verdict, headline and findings. Over the API that is a second run with task: "fix", the edited paste as code, facts recomputed from the edited paste, and that string. The page drops the handoff as soon as the paste is edited again.

Guards the page applies before a run and the API does not: it refuses to price or run a paste it cannot read as Python (“Nothing was pasted.”, “No Python file was found. Paste .py source, with a # file: <path>.py line above each file.”, or “This does not read as Python source: no def, class, import or call was found.”), and it warns before a run on a paste with no Temporal code (“No Temporal workflow or activity was found: there may be little here to audit.”, or “... to fix.”) or with no workflow class (“No @workflow.defn class was found; the run will see activities and the worker only.”).

Input fields

This is exactly what the page sends (tempkit.js buildInput(lane, A, note, handoff), checked by mustBeObject; app.js currentInput and run), and what input-schema.json declares. The platform’s input schema is scalar-only, so every value is a string. buildInput emits them in the order task, code, facts, note, audit_handoff:

FieldTypeRequiredMeaning
taskstringyes"audit" or "fix" (the lanes above).
codestringyesThe pasted Python, each file introduced by a # file: <path> line; line 1 is its first line. Every line in an audit reply is counted in this text. A large paste is cut on whole files, with a # [annalward: not sent: ...] line at the end (below).
factsstringyesThe page’s free check, Tempkit.facts(A), JSON-encoded into a string: JSON.stringify(facts) in JavaScript, json.dumps(facts) in Python, and so on. Sending the object itself is a 400. The model is told to parse it.
audit_handoffstringnofix lane only: a JSON string built from an audit reply on this code (recon.js fixHandoff, below). buildInput drops it for the audit lane.
notestringnoWhat the engineer says: the goal, a constraint (“no new dependencies”, “these workflows are already running in production”), a question. The page trims it, cuts it at 2000 characters and sends it only when it is not blank. It is context, not evidence and not instructions: the model tests every claim in it against code and answers it in summary.
retry_notestringnoSent only by the page’s automatic reformat retry: when a reply does not parse as JSON (even after the page tries to close a cut-off object), the page runs once more with “Your previous reply was not the single valid JSON object the instructions require (...). Reply again with ONLY the JSON object for task '<lane>' - no prose, no code fences; every array present (empty arrays where there is nothing to say).” A normal call never sends it.

mustBeObject only insists on a plain object that is not wrapped in {"input": ...}; it is buildInput that leaves a blank note out, which is why an empty note is simply absent.

The code string

code is the paste itself. A line # file: <path> (a path with a . or / in it) starts each file, and a --- line just before it only separates files. Files dropped on the page (or a dropped package folder) are joined that way by Tempkit.joinFiles: each file under its # file: line, trailing whitespace removed, joined by newlines, ending in a newline. A paste with no # file: line is read as one file, workflows.py. The page normalises line endings to \n. A .py (or .pyi) path is checked as Python; pyproject.toml, requirements*.txt, setup.cfg, Pipfile and poetry.lock are deps (they give facts.service.temporalio and facts.service.python_version, the version specs as declared); anything else is other. Workflow code is the methods of @workflow.defn classes; helpers they call in modules that are not pasted are outside the check. Python indents with four spaces and the paste keeps them. Send the text you want the reply to quote: every file in an audit reply must be a path from facts.files, every line must fall inside that file, every evidence must be copied from it, and every edit’s before must occur in it exactly once.

The page reads up to 400 000 characters (MAX_TEXT, cut at a line) and 40 files (MAX_FILES), and sends at most 60 000 characters (MAX_SEND): past that buildInput drops whole files from the end — the last non-Python file first, then the last file — until the rest fits, cuts a single remaining file at a line (“the end of <path>”) if it still does not, and ends the string with one line naming what it left out:

# [annalward: not sent: orders/worker.py, pyproject.toml]

The model is told never to quote or rewrite what was not sent, and facts.notes gets the sentence “Not sent (the paste is over 60,000 characters): ... The page checked them; the run did not see them.” facts is still computed from the whole paste. The worked examples below are small, so their code is the paste unchanged.

The facts string

facts is optional to compute but required to send. It is produced by the page’s free check: Tempkit.analyze splits the paste into files, lexes them, rebuilds their blocks, inventories them and runs the rules, and Tempkit.facts(A) turns that analysis into the object below (it does not depend on the lane: an audit and a fix run of the same paste carry the same facts). It is how the model knows which flags to answer, which file paths and line ranges to name, and which functions, workflows and handlers must survive. Its top-level keys, in the order built:

KeyContents
servicepython_version and temporalio (the version specs a pasted pyproject.toml declares, or null), and counts: python_files, workflows, activities, activity_calls, workers.
filesEvery file: path (use it verbatim), kind (python, deps, other), line and end_line (the paste lines of the file’s content, without its # file: line).
functionsEvery def (up to 200): name (Class.method for methods), file, line, async, and role (run, signal, query, update, validator, activity) when it has one.
workflowsEvery @workflow.defn class (up to 40): name, file, line, run, signals, queries, updates, sandboxed.
activitiesEvery @activity.defn function (up to 120): name, activity_name, file, line, async, heartbeats.
activity_callsEvery workflow.execute_activity / start_activity (and the _method and local variants; up to 160): file, line, in, kind, activity, start_to_close, schedule_to_close, heartbeat (the text given, or null), retry_policy, max_attempts, awaited.
workersEvery Worker(...) (up to 20): file, line, task_queue, workflows, activities (null when not a literal list), activity_executor.
compensationsEvery compensation list in workflow code (up to 40): file, line, workflow, list, added, order (reverse, forward, never, unknown), replayed_at.
workflow_exceptsEvery except in workflow code (up to 60): file, line, type, raises, in.
flagsEvery flag the page raised: uid (F1, F2, ... in severity, file, then line order), id (TW-01..TW-22), sev (high | medium | low | info), file, line, text, detail, and lines (every line an aggregated flag covers, only when it covers more than one). An audit must answer every non-info flag by its uid; a fix must close every high and medium flag or name it in open.
verdict_candidateThe audit verdict the flags point at (the page’s own reading): blocked with any high flag, needs_work with any medium, otherwise ready.
notesPlain sentences about what was not read or not sent (empty for a paste that fits).

The simplest source is tempkit.js itself: the page loads it from /tempkit.js (as window.Tempkit), and it also runs in Node (it sets module.exports). This script prints what the page would send as code and facts (with the 60 000-character cut applied), with the facts decoded so you can read them; the step 4 samples compact them back into the page’s string byte for byte. Save the paste as renewals-paste.txt and redirect the output into renewals-check.json:

// build-check.js -- run: node build-check.js renewals-paste.txt > renewals-check.json   (tempkit.js saved next to it)
const fs = require("fs");
const Tempkit = require("./tempkit.js");
const A = Tempkit.analyze(fs.readFileSync(process.argv[2], "utf8"));
if (!A.ok) { console.error(A.errors.join("\n")); process.exit(1); }
const inp = Tempkit.mustBeObject(Tempkit.buildInput("audit", A, "", null));   // code and facts do not depend on the lane
console.log(JSON.stringify({ code: inp.code, facts: JSON.parse(inp.facts) }, null, 2));
const real = A.flags.filter((f) => f.sev !== "info");
console.error(A.files.length + " file(s), " + A.workflows.length + " workflow(s), " + A.activities.length + " activity function(s), " +
  A.activity_calls.length + " activity call(s), " + A.workers.length + " Worker(s); " +
  real.length + " flags (" + A.counts.high + " high, " + A.counts.medium + " medium, " + A.counts.low + " low); verdict_candidate " + A.candidate);

For the renewals example it reports 4 file(s), 1 workflow(s), 2 activity function(s), 2 activity call(s), 1 Worker(s); 4 flags (0 high, 3 medium, 1 low); verdict_candidate needs_work; for the payouts example, 4 file(s), 1 workflow(s), 2 activity function(s), 2 activity call(s), 1 Worker(s); 4 flags (4 high, 0 medium, 0 low); verdict_candidate blocked. For both worked examples its code and facts are exactly what the page sends.

Without the page’s check. The platform only checks that facts is a string, so a minimal object works — "{}", or a hand-built one such as {"flags": [], "notes": ["No page check was run; read the code directly."]} — and the model still reads code. But you lose what the check adds: with no flags there is nothing to confirm, dismiss or close, with no files there are no paths and line ranges to name, and with no functions and workflows nothing says what must survive a fix. Build it with tempkit.js as above when you can.

The audit_handoff string

Only for task: "fix", and only for the code the audit was about with every audit edit applied. The page builds both from the normalised audit reply res (recon.js normalize, below) and the analysis A of the audited paste, in recon.js fixHandoff:

const run = Recon.applyFindings(A, res.findings, Tempkit);   // every finding's edits, in order, each on top of the last
JSON.stringify({ verdict: res.verdict, headline: res.headline,
  findings: res.findings.map(f => ({ id: f.id, severity: f.severity, file: f.file, line: f.line, title: f.title, why: f.why, edits: f.edits.length })),
  applied: /* edits that applied */, failed: /* edits that did not */ })
// run.A.text is the edited paste: it becomes code, and facts is recomputed from it

That is {"verdict", "headline", "findings": [{id, severity, file, line, title, why, edits}], "applied", "failed"}, where each finding’s edits is the number of edits it carried. applyFindings applies every finding’s edits with Tempkit.applyEdits, in the order listed: each before must occur exactly once in its file as the earlier edits left it (leading and trailing blank lines ignored), or that edit is reported as failed and skipped — it is never guessed. The handoff is a previous reply, not evidence: the model checks it against code and fixes what is left. This script does the whole step — the edited paste, its recomputed facts and the handoff string, in the same shape as build-check.js prints, so the samples read it the same way:

// handoff.js -- run: node handoff.js renewals-paste.txt renewals-reply.txt > renewals-fix-check.json   (tempkit.js, recon.js next to it)
const fs = require("fs");
const Tempkit = require("./tempkit.js");
const Recon = require("./recon.js");
const A = Tempkit.analyze(fs.readFileSync(process.argv[2], "utf8"));           // the audited paste
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), "audit");
const ho = Recon.fixHandoff(A, res);                                            // every edit, in order
const B = Tempkit.analyze(ho.text);                                             // facts recomputed from the edited paste
const inp = Tempkit.buildInput("fix", B, "", ho.handoff);
console.log(JSON.stringify({ code: inp.code, facts: JSON.parse(inp.facts), audit_handoff: inp.audit_handoff }, null, 2));
console.error(ho.applied + " edit(s) applied, " + ho.failed + " did not apply");

Save the audit’s output.output as renewals-reply.txt; for the renewals audit below it reports 8 edit(s) applied, 0 did not apply, the edited paste raises no flag beyond the TW-22 context line, and the page sends that fix run with the audit’s note and the key annalward:fix:6bf3d7d4-2d57:a1.

The output contracts

The model is instructed (SKILL.md) to reply with one JSON object and nothing else — no prose, no code fences — with every array of its lane present ([] when empty). The run’s output text is that object. In both lanes it must ground everything in code: a caller, a client call that starts the workflow, a schedule, a search attribute, a namespace setting, a worker option or a running execution that is not in the paste is never claimed to exist — it goes into questions; whether an imported helper is deterministic, or an external API is idempotent, is unknown unless the paste shows it. It must never write a secret value: a line that holds a literal credential is never copied into the reply (not in evidence, not in an edit, not in a file), and no credential, real hostname or endpoint is invented. It must never change what the code does for its callers: same workflow and activity classes and functions, names, parameters and return values on the paths that worked, same signal, query and update names, same sync-or-async nature of every function, same task queues, unless the note asks for it. An edit that adds, removes or reorders activity calls or timers breaks executions already in flight, so when the paste gives no sign the workflow is not yet deployed the reply says it needs workflow.patched(...) (or a new workflow type, or draining) and asks whether executions are running — and it never writes workflow.patched into an edit unless the note says they are. Every page flag whose sev is not info is answered exactly once (audit lane: in flag_answers), and an info flag is never reported as a finding.

task: "audit"

task: "fix"

How the page reads a reply (recon.js parseResult and normalize): it strips a code fence and parses from the first { to the last }. A reply is read as the lane that was asked for, unless it names the other lane and carries that lane’s body (a files array for fix, a findings array for audit). An unknown verdict shows as needs_work or partial and counts as a disagreement; an unknown severity becomes medium; ids like TW7 or tw 7 become TW-07; flags keeps only F<n> values; findings with no file, evidence and title are dropped; system may also arrive as service, file as path, a change’s function as func and its change as what, and an open item as path; questions is capped at 10, plan at 30 and verify at 20. An audit reply carries 9 sections (verdict, headline, system, areas, findings, flag_answers, plan, questions, summary) and a fix reply 8 (verdict, headline, files, changes, verify, open, questions, summary), which is what the page counts when it recovers a partial reply.

Worked examples

Both are examples the page ships (the example buttons on the app page; the full pastes are in /example.js, ids renewals and payouts), built exactly as the page builds them: Tempkit.buildInput(lane, Tempkit.analyze(text), note, null), with the saved model reply the page replays for each. The pastes are shown whole; the request bodies cut the long code and facts strings with a visible ..., and the replies are pretty-printed excerpts, with ... where entries were left out. Every value shown is copied from the input or the reply. Neither paste holds a credential; the billing and ledger hosts are invented .test names.

Audit: a subscription workflow that loops for the account’s lifetime, heartbeats nothing its heartbeat timeout waits for and counts reads in a query

Four files: pyproject.toml (requires-python = ">=3.10", temporalio>=1.6), renewals/activities.py, renewals/workflows.py and renewals/worker.py. SubscriptionWorkflow.run waits 30 days per cycle on workflow.wait_condition (catching asyncio.TimeoutError), charges the renewal and exports usage, in a while not self.cancelled: loop that never continues as new; the export_usage call sets heartbeat_timeout, but the activity pages through usage without ever calling activity.heartbeat; the status query increments a counter; and run logs through the stdlib logger. The page’s free check raises 4 flags (0 high, 3 medium, 1 low) (plus the TW-22 context flag); its own reading of the verdict is needs_work. The engineer’s note: “Each subscription runs as one workflow for as long as the account exists. Anything that will bite us in a year?”

The code string, whole: 3,600 characters, 118 lines ending in a newline. Save it as renewals-paste.txt and run node build-check.js renewals-paste.txt > renewals-check.json:

# file: pyproject.toml
[project]
name = "subscription-renewals"
version = "2.1.0"
requires-python = ">=3.10"
dependencies = ["temporalio>=1.6", "httpx>=0.27"]
---
# file: renewals/activities.py
import httpx
from temporalio import activity

BILLING_API = "https://billing.example.test/v3"


@activity.defn
async def charge_renewal(account_id: str, plan: str) -> str:
    async with httpx.AsyncClient(timeout=30) as client:
        resp = await client.post(f"{BILLING_API}/renewals", json={"account": account_id, "plan": plan}, headers={"Idempotency-Key": f"renew-{account_id}"})
        resp.raise_for_status()
        return resp.json()["invoice_id"]


@activity.defn
async def export_usage(account_id: str) -> int:
    rows = 0
    async with httpx.AsyncClient(timeout=30) as client:
        page = 1
        while True:
            resp = await client.get(f"{BILLING_API}/usage", params={"account": account_id, "page": page})
            resp.raise_for_status()
            batch = resp.json()["rows"]
            if not batch:
                return rows
            rows += len(batch)
            page += 1
---
# file: renewals/workflows.py
import asyncio
import logging
from datetime import timedelta

from temporalio import workflow
from temporalio.common import RetryPolicy

with workflow.unsafe.imports_passed_through():
    from renewals.activities import charge_renewal, export_usage

log = logging.getLogger(__name__)

BILLING_RETRY = RetryPolicy(initial_interval=timedelta(seconds=5), maximum_attempts=5)


@workflow.defn
class SubscriptionWorkflow:
    def __init__(self) -> None:
        self.plan = "basic"
        self.cancelled = False
        self.renewals = 0
        self.status_reads = 0

    @workflow.run
    async def run(self, account_id: str, plan: str) -> int:
        self.plan = plan
        while not self.cancelled:
            try:
                await workflow.wait_condition(lambda: self.cancelled, timeout=timedelta(days=30))
            except asyncio.TimeoutError:
                pass
            if self.cancelled:
                break
            invoice = await workflow.execute_activity(
                charge_renewal,
                args=[account_id, self.plan],
                start_to_close_timeout=timedelta(minutes=2),
                retry_policy=BILLING_RETRY,
            )
            self.renewals += 1
            log.info("renewed %s: invoice %s", account_id, invoice)
            await workflow.execute_activity(
                export_usage,
                account_id,
                start_to_close_timeout=timedelta(hours=1),
                heartbeat_timeout=timedelta(minutes=2),
                retry_policy=BILLING_RETRY,
            )
        return self.renewals

    @workflow.signal
    def change_plan(self, plan: str) -> None:
        self.plan = plan

    @workflow.signal
    def cancel(self) -> None:
        self.cancelled = True

    @workflow.query
    def status(self) -> dict:
        self.status_reads += 1
        return {"plan": self.plan, "renewals": self.renewals, "cancelled": self.cancelled}
---
# file: renewals/worker.py
import asyncio

from temporalio.client import Client
from temporalio.worker import Worker

from renewals.activities import charge_renewal, export_usage
from renewals.workflows import SubscriptionWorkflow


async def main() -> None:
    client = await Client.connect("localhost:7233")
    worker = Worker(client, task_queue="renewals", workflows=[SubscriptionWorkflow], activities=[charge_renewal, export_usage])
    await worker.run()


if __name__ == "__main__":
    asyncio.run(main())

Its facts, decoded (the request carries this object as one compact JSON string):

{
  "service": {
    "python_version": ">=3.10",
    "temporalio": ">=1.6",
    "python_files": 3,
    "workflows": 1,
    "activities": 2,
    "activity_calls": 2,
    "workers": 1
  },
  "files": [
    {
      "path": "pyproject.toml",
      "kind": "deps",
      "line": 2,
      "end_line": 6
    },
    {
      "path": "renewals/activities.py",
      "kind": "python",
      "line": 9,
      "end_line": 35
    },
    {
      "path": "renewals/workflows.py",
      "kind": "python",
      "line": 38,
      "end_line": 99
    },
    {
      "path": "renewals/worker.py",
      "kind": "python",
      "line": 102,
      "end_line": 118
    }
  ],
  "functions": [
    {
      "name": "charge_renewal",
      "file": "renewals/activities.py",
      "line": 16,
      "async": true,
      "role": "activity"
    },
    {
      "name": "export_usage",
      "file": "renewals/activities.py",
      "line": 24,
      "async": true,
      "role": "activity"
    },
    {
      "name": "SubscriptionWorkflow.__init__",
      "file": "renewals/workflows.py",
      "line": 55,
      "async": false
    },
    {
      "name": "SubscriptionWorkflow.run",
      "file": "renewals/workflows.py",
      "line": 62,
      "async": true,
      "role": "run"
    },
    {
      "name": "SubscriptionWorkflow.change_plan",
      "file": "renewals/workflows.py",
      "line": 89,
      "async": false,
      "role": "signal"
    },
    {
      "name": "SubscriptionWorkflow.cancel",
      "file": "renewals/workflows.py",
      "line": 93,
      "async": false,
      "role": "signal"
    },
    {
      "name": "SubscriptionWorkflow.status",
      "file": "renewals/workflows.py",
      "line": 97,
      "async": false,
      "role": "query"
    },
    {
      "name": "main",
      "file": "renewals/worker.py",
      "line": 111,
      "async": true
    }
  ],
  "workflows": [
    {
      "name": "SubscriptionWorkflow",
      "file": "renewals/workflows.py",
      "line": 54,
      "run": "run",
      "signals": [
        "change_plan",
        "cancel"
      ],
      "queries": [
        "status"
      ],
      "updates": [],
      "sandboxed": true
    }
  ],
  "activities": [
    {
      "name": "charge_renewal",
      "activity_name": "charge_renewal",
      "file": "renewals/activities.py",
      "line": 16,
      "async": true,
      "heartbeats": false
    },
    {
      "name": "export_usage",
      "activity_name": "export_usage",
      "file": "renewals/activities.py",
      "line": 24,
      "async": true,
      "heartbeats": false
    }
  ],
  "activity_calls": [
    {
      "file": "renewals/workflows.py",
      "line": 71,
      "in": "SubscriptionWorkflow.run",
      "kind": "execute_activity",
      "activity": "charge_renewal",
      "start_to_close": "timedelta(minutes=2)",
      "schedule_to_close": null,
      "heartbeat": null,
      "retry_policy": true,
      "max_attempts": 5,
      "awaited": true
    },
    {
      "file": "renewals/workflows.py",
      "line": 79,
      "in": "SubscriptionWorkflow.run",
      "kind": "execute_activity",
      "activity": "export_usage",
      "start_to_close": "timedelta(hours=1)",
      "schedule_to_close": null,
      "heartbeat": "timedelta(minutes=2)",
      "retry_policy": true,
      "max_attempts": 5,
      "awaited": true
    }
  ],
  "workers": [
    {
      "file": "renewals/worker.py",
      "line": 113,
      "task_queue": "renewals",
      "workflows": [
        "SubscriptionWorkflow"
      ],
      "activities": [
        "charge_renewal",
        "export_usage"
      ],
      "activity_executor": false
    }
  ],
  "compensations": [],
  "workflow_excepts": [
    {
      "file": "renewals/workflows.py",
      "line": 67,
      "type": "asyncio.TimeoutError",
      "raises": false,
      "in": "SubscriptionWorkflow.run"
    }
  ],
  "flags": [
    {
      "uid": "F1",
      "id": "TW-18",
      "sev": "medium",
      "file": "renewals/workflows.py",
      "line": 64,
      "text": "while not self.cancelled: in SubscriptionWorkflow.run loops for the workflow's whole life and never continues as new",
      "detail": "Every activity, timer and signal in the loop appends events to one history; after enough iterations the history passes Temporal's limits (about 51,200 events or 50 MB) and the workflow is terminated, and long before that every replay gets slower. Call workflow.continue_as_new(state) when workflow.info().is_continue_as_new_suggested() is true, or after a fixed number of iterations."
    },
    {
      "uid": "F2",
      "id": "TW-12",
      "sev": "medium",
      "file": "renewals/workflows.py",
      "line": 79,
      "text": "workflow.execute_activity(export_usage) sets heartbeat_timeout, but export_usage() never calls activity.heartbeat()",
      "detail": "With a heartbeat_timeout the server expects a heartbeat at least that often; an activity that never heartbeats is timed out as soon as one attempt runs longer than the heartbeat timeout, and every retry fails the same way. Call activity.heartbeat(progress) inside its loop, or drop heartbeat_timeout for a short activity."
    },
    {
      "uid": "F3",
      "id": "TW-19",
      "sev": "medium",
      "file": "renewals/workflows.py",
      "line": 98,
      "text": "query SubscriptionWorkflow.status() changes workflow state",
      "detail": "A query is answered by replaying the workflow and must be read-only: a change it makes is not recorded in history, is lost on the next replay, and differs between workers, so the workflow's state depends on who asked and when. Keep the query a pure read; change state in a signal or an update handler."
    },
    {
      "uid": "F4",
      "id": "TW-07",
      "sev": "low",
      "file": "renewals/workflows.py",
      "line": 78,
      "text": "workflow SubscriptionWorkflow logs with the stdlib logger or print in workflow code (log.info())",
      "detail": "Workflow code is replayed, so a stdlib logger or print writes every line again on every replay and after every worker restart. workflow.logger is replay-aware: it skips records during replay and tags each record with the workflow id and run id."
    },
    {
      "uid": "F5",
      "id": "TW-22",
      "sev": "info",
      "file": "renewals/activities.py",
      "line": 9,
      "text": "context: 3 Python files, 1 workflow, 2 activities (0 sync), 2 activity call(s), 0 compensation list(s), 1 Worker(s), temporalio >=1.6, Python >=3.10",
      "detail": "Also read: pyproject.toml (deps). Only what is pasted is checked: workflow code is the methods of @workflow.defn classes (helpers they call in other modules are outside the check), and whether an activity is idempotent, and how a change replays against running workflows, is for the audit and for the replayer."
    }
  ],
  "verdict_candidate": "needs_work",
  "notes": []
}

The request body (POST /estimate, then POST /run or /run-stream with Idempotency-Key: annalward:audit:ede4d6c7-25e5:a1 as the page sends it):

{
  "task": "audit",
  "code": "# file: pyproject.toml\n[project]\nname = \"subscription-renewals\"\nversion = \"2.1.0\"\nrequires-python = \">=3.10\"\ndependencies = [\"temporalio>=1.6\", \"httpx>=0.27\"]\n---\n# file: renewals/activities.py\nimport httpx\nfrom temporalio import activity\n\nBILLING_API = \"https://billing.exampl...",
  "facts": "{\"service\":{\"python_version\":\">=3.10\",\"temporalio\":\">=1.6\",\"python_files\":3,\"workflows\":1,\"activities\":2,\"activity_calls\":2,\"workers\":1},\"files\":[{\"path\":\"pyproject.toml\",\"...",
  "note": "Each subscription runs as one workflow for as long as the account exists. Anything that will bite us in a year?"
}

The reply (output.output), an excerpt: the verdict, the headline, the system, each area’s status, and the finding that answers “anything that will bite us in a year?” most directly — the loop that never continues as new — with its edit, its flag answer, the last step of the plan and the first question:

{
  "task": "audit",
  "verdict": "needs_work",
  "headline": "SubscriptionWorkflow's renewal charge reuses the same Idempotency-Key forever and its loop never calls continue_as_new, so long-lived accounts risk silent duplicate-charge dedup and unbounded history growth.",
  "system": "SubscriptionWorkflow runs one long-lived workflow per account that periodically calls charge_renewal to bill a renewal and export_usage to pull usage rows; both activities call the billing API over HTTP with httpx.",
  "areas": [
    {
      "area": "determinism",
      "status": "sound"
    },
    {
      "area": "activities",
      "status": "fragile"
    },
    {
      "area": "compensation",
      "status": "sound"
    },
    {
      "area": "operations",
      "status": "fragile"
    }
  ],
  "findings": [
    ...,
    {
      "id": "TW-18",
      "flags": [
        "F1"
      ],
      "file": "renewals/workflows.py",
      "line": 64,
      "severity": "medium",
      "area": "operations",
      "title": "Renewal loop never continues-as-new",
      "evidence": "async def run(self, account_id: str, plan: str) -> int:\nself.plan = plan\nwhile not self.cancelled:",
      "why": "Each iteration appends a timer and two activity results to this one run's history for as long as the account keeps renewing, which the note says is the account's whole life. Left unbounded the history approaches Temporal's ~51,200-event/50MB limit, so call workflow.continue_as_new when workflow.info().is_continue_as_new_suggested() and carry plan and renewals forward.",
      "edits": [
        {
          "before": "    @workflow.run\n    async def run(self, account_id: str, plan: str) -> int:\n        self.plan = plan\n        while not self.cancelled:\n            try:\n                await workflow.wait_condition(lambda: self.cancelled, timeout=timedelta(days=30))\n            except asyncio.TimeoutError:\n                pass\n            if self.cancelled:\n                break\n            invoice = await workflow.execute_activity(\n                charge_renewal,\n                args=[account_id, self.plan, self.renewals],\n                start_to_close_timeout=timedelta(minutes=2),\n                retry_policy=BILLING_RETRY,\n            )\n            self.renewals += 1\n            log.info(\"renewed %s: invoice %s\", account_id, invoice)\n            await workflow.execute_activity(\n                export_usage,\n                account_id,\n                start_to_close_timeout=timedelta(hours=1),\n                heartbeat_timeout=timedelta(minutes=2),\n                retry_policy=BILLING_RETRY,\n            )\n        return self.renewals",
          "after": "    @workflow.run\n    async def run(self, account_id: str, plan: str, renewals: int = 0) -> int:\n        self.plan = plan\n        self.renewals = renewals\n        while not self.cancelled:\n            try:\n                await workflow.wait_condition(lambda: self.cancelled, timeout=timedelta(days=30))\n            except asyncio.TimeoutError:\n                pass\n            if self.cancelled:\n                break\n            invoice = await workflow.execute_activity(\n                charge_renewal,\n                args=[account_id, self.plan, self.renewals],\n                start_to_close_timeout=timedelta(minutes=2),\n                retry_policy=BILLING_RETRY,\n            )\n            self.renewals += 1\n            log.info(\"renewed %s: invoice %s\", account_id, invoice)\n            await workflow.execute_activity(\n                export_usage,\n                account_id,\n                start_to_close_timeout=timedelta(hours=1),\n                heartbeat_timeout=timedelta(minutes=2),\n                retry_policy=BILLING_RETRY,\n            )\n            if workflow.info().is_continue_as_new_suggested():\n                workflow.continue_as_new(args=[account_id, self.plan, self.renewals])\n        return self.renewals"
        }
      ]
    },
    ...
  ],
  "flag_answers": [
    {
      "flag": "F1",
      "status": "confirmed",
      "reason": "run() loops for the account's whole life with no continue_as_new; fixed by calling it when is_continue_as_new_suggested() is true."
    },
    ...
  ],
  "plan": [
    ...,
    "Record a history of SubscriptionWorkflow and replay it with temporalio.worker.Replayer after these changes, and exercise the renewal/cancel/continue-as-new paths with temporalio.testing.WorkflowEnvironment.start_time_skipping()."
  ],
  "questions": [
    "Are any SubscriptionWorkflow executions already running in production? The continue_as_new change adds a new decision point mid-loop, which needs workflow.patched(...), a new workflow type, or draining in-flight executions to replay safely if so - we did not add patched since the note does not confirm executions are running.",
    ...
  ],
  "summary": ...
}

The reply has 7 findings carrying 8 edits and confirms all 4 flags. Two findings the page did not flag (other, medium, flags: []) come first: charge_renewal builds its Idempotency-Key from account_id alone, so every renewal after the first reuses the first renewal’s key; the fix gives the activity a cycle parameter (renewals/activities.py) and passes self.renewals as it (renewals/workflows.py). Then activity.heartbeat(rows) after each page of export_usage (F2), continue_as_new when is_continue_as_new_suggested(), carrying renewals forward (F1), the counter removed from the status query (F3), and workflow.logger in place of the stdlib logger (F4). A third unflagged finding (low, two edits) closes the list: a permanent 4xx from billing is retried like a transient failure, so charge_renewal raises ApplicationError(..., non_retryable=True) for it. The TW-18 edit’s before already carries the self.renewals argument the second finding wrote, as the in-order contract requires. Because continue-as-new adds a decision mid-loop, the reply asks whether executions are already running rather than writing workflow.patched into an edit, and asks whether accounts renewed more than once have already been under-billed.

Fix: a payout batch whose worker cannot start, whose timeout is a number, whose activity call passes three positional arguments and whose workflow calls time.sleep

Four files: pyproject.toml (requires-python = ">=3.11", temporalio>=1.7), payouts/activities.py with two plain def activities on requests, payouts/workflows.py and payouts/worker.py. PayoutBatchWorkflow.run gives fetch_balance start_to_close_timeout=30, passes send_payout three positional arguments and pauses with time.sleep(1); the Worker registers the two sync activities with no activity_executor. The free check raises 4 flags (4 high, 0 medium, 0 low); its reading of the verdict is blocked. This example has no audit before it, so it carries no audit_handoff. The engineer’s note: “The payout worker will not even start. Fix it. Keep the two activities as plain def functions - the ledger client is sync.”

The code string, whole: 2,453 characters, 86 lines ending in a newline. Save it as payouts-paste.txt and run node build-check.js payouts-paste.txt > payouts-check.json:

# file: pyproject.toml
[project]
name = "creator-payouts"
version = "0.9.1"
requires-python = ">=3.11"
dependencies = ["temporalio>=1.7", "requests>=2.31"]
---
# file: payouts/activities.py
import requests
from temporalio import activity

LEDGER_API = "https://ledger.example.test/v1"


@activity.defn
def fetch_balance(creator_id: str) -> int:
    resp = requests.get(f"{LEDGER_API}/balances/{creator_id}", timeout=10)
    resp.raise_for_status()
    return resp.json()["cents"]


@activity.defn
def send_payout(creator_id: str, cents: int, batch_id: str) -> str:
    resp = requests.post(
        f"{LEDGER_API}/payouts",
        json={"creator": creator_id, "cents": cents},
        headers={"Idempotency-Key": f"{batch_id}-{creator_id}"},
        timeout=20,
    )
    resp.raise_for_status()
    return resp.json()["payout_id"]
---
# file: payouts/workflows.py
import time
from datetime import timedelta

from temporalio import workflow
from temporalio.common import RetryPolicy

with workflow.unsafe.imports_passed_through():
    from payouts.activities import fetch_balance, send_payout

MIN_PAYOUT_CENTS = 1000


@workflow.defn
class PayoutBatchWorkflow:
    @workflow.run
    async def run(self, batch_id: str, creator_ids: list[str]) -> list[str]:
        paid = []
        for creator_id in creator_ids:
            cents = await workflow.execute_activity(
                fetch_balance, creator_id, start_to_close_timeout=30, retry_policy=RetryPolicy(maximum_attempts=3)
            )
            if cents < MIN_PAYOUT_CENTS:
                continue
            payout_id = await workflow.execute_activity(
                send_payout,
                creator_id,
                cents,
                batch_id,
                start_to_close_timeout=timedelta(minutes=1),
                retry_policy=RetryPolicy(maximum_attempts=5),
            )
            paid.append(payout_id)
            time.sleep(1)
        return paid
---
# file: payouts/worker.py
import asyncio

from temporalio.client import Client
from temporalio.worker import Worker

from payouts.activities import fetch_balance, send_payout
from payouts.workflows import PayoutBatchWorkflow


async def main() -> None:
    client = await Client.connect("localhost:7233")
    worker = Worker(client, task_queue="payouts", workflows=[PayoutBatchWorkflow], activities=[fetch_balance, send_payout])
    await worker.run()


if __name__ == "__main__":
    asyncio.run(main())

Its facts, decoded, cut to files, workers, flags and verdict_candidate (the full object also has the service counts, the 4 functions with their roles, the one workflow, the 2 sync activities, the 2 activity calls with their timeouts and retry bounds, and empty compensations and workflow_excepts):

{
  "files": [
    {
      "path": "pyproject.toml",
      "kind": "deps",
      "line": 2,
      "end_line": 6
    },
    {
      "path": "payouts/activities.py",
      "kind": "python",
      "line": 9,
      "end_line": 31
    },
    {
      "path": "payouts/workflows.py",
      "kind": "python",
      "line": 34,
      "end_line": 67
    },
    {
      "path": "payouts/worker.py",
      "kind": "python",
      "line": 70,
      "end_line": 86
    }
  ],
  "workers": [
    {
      "file": "payouts/worker.py",
      "line": 81,
      "task_queue": "payouts",
      "workflows": [
        "PayoutBatchWorkflow"
      ],
      "activities": [
        "fetch_balance",
        "send_payout"
      ],
      "activity_executor": false
    }
  ],
  "flags": [
    {
      "uid": "F1",
      "id": "TW-09",
      "sev": "high",
      "file": "payouts/workflows.py",
      "line": 52,
      "text": "workflow.execute_activity(fetch_balance) in PayoutBatchWorkflow.run gives start_to_close_timeout as a number",
      "detail": "The SDK takes a datetime.timedelta for every activity timeout; a bare number does not convert, the call raises when the activity is scheduled, and the workflow task fails on every retry. Write timedelta(seconds=30) - and say the unit, since seconds and minutes are easy to confuse."
    },
    {
      "uid": "F2",
      "id": "TW-10",
      "sev": "high",
      "file": "payouts/workflows.py",
      "line": 57,
      "text": "workflow.execute_activity(send_payout, ...) in PayoutBatchWorkflow.run passes 3 positional arguments to the activity",
      "detail": "execute_activity takes the activity, then at most one positional argument; everything after it is keyword-only, so extra positional arguments raise TypeError when the workflow reaches this line. Pass several arguments as args=[a, b, c] (or one dataclass), matching the activity's parameters in order."
    },
    {
      "uid": "F3",
      "id": "TW-04",
      "sev": "high",
      "file": "payouts/workflows.py",
      "line": 66,
      "text": "PayoutBatchWorkflow.run calls time.sleep in workflow code (time.sleep())",
      "detail": "time.sleep blocks the workflow's event loop and the worker thread instead of creating a durable timer: nothing is recorded in history, the worker cannot run other workflow tasks, and the deadlock detector fails the task after two seconds. Use await asyncio.sleep(...), which Temporal turns into a durable timer."
    },
    {
      "uid": "F4",
      "id": "TW-21",
      "sev": "high",
      "file": "payouts/worker.py",
      "line": 81,
      "text": "the Worker registers the sync activities fetch_balance, send_payout but has no activity_executor",
      "detail": "A plain def activity runs in a thread or process pool that the Worker must be given; without activity_executor=... the Worker refuses to start, so no workflow or activity on this task queue runs. Pass activity_executor=concurrent.futures.ThreadPoolExecutor(max_workers=...) - or make the activities async def with async clients."
    },
    {
      "uid": "F5",
      "id": "TW-22",
      "sev": "info",
      "file": "payouts/activities.py",
      "line": 9,
      "text": "context: 3 Python files, 1 workflow, 2 activities (2 sync), 2 activity call(s), 0 compensation list(s), 1 Worker(s), temporalio >=1.7, Python >=3.11",
      "detail": "Also read: pyproject.toml (deps). Only what is pasted is checked: workflow code is the methods of @workflow.defn classes (helpers they call in other modules are outside the check), and whether an activity is idempotent, and how a change replays against running workflows, is for the audit and for the replayer."
    }
  ],
  "verdict_candidate": "blocked"
}

The request body (Idempotency-Key: annalward:fix:a9ad0833-1e49:a1):

{
  "task": "fix",
  "code": "# file: pyproject.toml\n[project]\nname = \"creator-payouts\"\nversion = \"0.9.1\"\nrequires-python = \">=3.11\"\ndependencies = [\"temporalio>=1.7\", \"requests>=2.31\"]\n---\n# file: payouts/activities.py\nimport requests\nfrom temporalio import activity\n\nLEDGER_API = \"https://ledger.example.t...",
  "facts": "{\"service\":{\"python_version\":\">=3.11\",\"temporalio\":\">=1.7\",\"python_files\":3,\"workflows\":1,\"activities\":2,\"activity_calls\":2,\"workers\":1},\"files\":[{\"path\":\"pyproject.toml\",\"...",
  "note": "The payout worker will not even start. Fix it. Keep the two activities as plain def functions - the ledger client is sync."
}

The reply (output.output), an excerpt: the verdict, the headline, the two returned files (the workflow file cut, the worker file whole), the change that lets the Worker start, the last verification step, and the empty open and questions lists:

{
  "task": "fix",
  "verdict": "fixed",
  "headline": "Fixed the two workflow non-determinism bugs, the illegal activity call, and the missing activity executor so the payout worker starts and the batch replays cleanly.",
  "files": [
    {
      "path": "payouts/workflows.py",
      "content": "import asyncio\nfrom datetime import timedelta\n\nfrom temporalio import workflow\nfrom temporalio.common import RetryPolicy\n\nwith workflow.unsafe.imports_passed_through():\n    from payouts.activities import fetch_balance, send_payout\n\nMIN_PAYOUT_CENTS = 1000\n\n\n..."
    },
    {
      "path": "payouts/worker.py",
      "content": "import asyncio\nimport concurrent.futures\n\nfrom temporalio.client import Client\nfrom temporalio.worker import Worker\n\nfrom payouts.activities import fetch_balance, send_payout\nfrom payouts.workflows import PayoutBatchWorkflow\n\n\nasync def main() -> None:\n    client = await Client.connect(\"localhost:7233\")\n    worker = Worker(\n        client,\n        task_queue=\"payouts\",\n        workflows=[PayoutBatchWorkflow],\n        activities=[fetch_balance, send_payout],\n        activity_executor=concurrent.futures.ThreadPoolExecutor(max_workers=10),\n    )\n    await worker.run()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
    }
  ],
  "changes": [
    ...,
    {
      "file": "payouts/worker.py",
      "function": "main",
      "flags": [
        "F4"
      ],
      "change": "Added activity_executor=concurrent.futures.ThreadPoolExecutor(max_workers=10) to the Worker construction and imported concurrent.futures.",
      "why": "A Worker registering plain def activities (fetch_balance, send_payout) refuses to start unless it is given an executor to run them in."
    }
  ],
  "verify": [
    ...,
    "In WorkflowEnvironment.start_time_skipping(), start a worker built exactly as the fixed payouts/worker.py does (task_queue=\"payouts\", the same activity_executor), run PayoutBatchWorkflow with send_payout mocked to raise once and then succeed, and assert the worker starts and the run still returns the payout_id - the test that would have caught F4 (worker refusing to start) and the send_payout retry behavior."
  ],
  "open": [],
  "questions": [],
  "summary": ...
}

The other three changes are all in PayoutBatchWorkflow.run: start_to_close_timeout=timedelta(seconds=30) for fetch_balance (F1), args=[creator_id, cents, batch_id] for send_payout (F2), and await asyncio.sleep(1) in place of time.sleep(1), with import time replaced by import asyncio (F3). Both activities stay plain def functions, as the note asked, so the verdict is fixed with nothing open; payouts/activities.py is not returned because nothing in it changed.

The page checks the reply against the code; the API does not

What comes back from the API is the model’s raw text in output.output. The page holds it to account in the browser before showing it (recon.js reconcile, with the facts, the code and the handoff it sent): it applies every edit or returned file to the paste and re-runs its own checks on the result. None of that happens in the API. A direct API caller gets the reply unchecked and must do the applying and re-checking itself.

recon.js also runs in Node, so you can hold a reply to exactly the page’s standard. Save /tempkit.js and /recon.js next to this script, pass the file you built the body from (build-check.js or handoff.js output), the reply text and the lane; it prints every statement, the count, and the paste with every edit (or file) put back:

// recheck.js -- run: node recheck.js renewals-check.json renewals-reply.txt audit > renewals-edited.txt   (tempkit.js, recon.js next to it)
const fs = require("fs");
const Recon = require("./recon.js");                                            // loads ./tempkit.js itself
const sent = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));             // code, facts (and audit_handoff) you sent
const lane = process.argv[4] || "audit";
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), lane);  // output.output
const r = Recon.reconcile(res, { facts: sent.facts, code: sent.code, handoff: sent.audit_handoff || null });
r.notes.forEach((n) => console.error(n.kind + ": " + n.text));
console.error(r.disagreements + " disagreement(s) in " + r.statements + " statements checked");
if (r.after) (r.after.flags || []).forEach((f) => console.error("still raised: " + f.id + " " + f.sev + " " + f.file + ":" + f.line + " " + f.text));
if (r.after && r.after.text) console.log(r.after.text.replace(/\n$/, ""));      // the paste with every edit (or file) put back

For the renewals audit reply it reports:

ok: The areas table rates determinism, activities, compensation and operations once each.
ok: Every one of the 7 findings names a pasted file, a line inside it and evidence copied from it.
ok: The verdict (needs_work) fits the worst finding.
ok: Every one of the page's 4 flags is answered once.
ok: All 8 edits applied in order, each before text found exactly once.
ok: Every confirmed flag a finding carries is gone once all edits are applied.
ok: Every function of the paste is still there after the edits.
ok: The reply repeats no credential from the paste.
0 disagreement(s) in 8 statements checked

and for the payouts fix reply (node recheck.js payouts-check.json payouts-reply.txt fix):

ok: 2 files returned whole.
ok: With the fixed files in place the free check raises no high or medium flag (0 low).
ok: Every one of the 4 functions is still there.
ok: Every one of the 4 changes names a returned file, a function in the fixed code and real page flags.
ok: The verdict (fixed) fits the open list.
ok: The reply repeats no credential from the paste.
0 disagreement(s) in 6 statements checked

Nothing is left raised in either case. The page does not run the code and is not the replayer. After that, record a history of the workflow and replay it with temporalio.worker.Replayer against the changed code, run the failure paths in temporalio.testing.WorkflowEnvironment.start_time_skipping(), and run the tests the reply’s plan or verify names, before you ship anything — and if executions are already running, put a changed workflow behind workflow.patched(...) first.

Step 1 — a tiny client helper

One function per language that does what sdk.js does for every call: JSON in, bearer token if you have one, data out on a 2xx, an error carrying status, error.code and error.message otherwise. The later steps assume this helper is in the same file (for Go, Java and C#, the later snippets go inside main / the top-level statements; Java’s makeInput from step 4 goes in the class). The token comes from SKILLSAFE_TOKEN, or the YOUR_TOKEN placeholder: copy yours from /tokens.html (step 2).

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}"   # step 2

# api METHOD PATH [extra curl args...] -- prints the whole envelope; pipe to jq
api() {
  method="$1"; path="$2"; shift 2
  curl -sS -X "$method" "$BASE$path" \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $TOKEN" "$@"
}
# success: jq '.data'      failure: jq '.error | {code, message}'

Step 2 — a token

Two kinds work:

# personal token (needed for a run): copy it from /tokens.html, then
#   export SKILLSAFE_TOKEN=...        and skip the rest of this block
# guest token (enough for /me and /estimate): no Authorization header on this one call
TOKEN=$(curl -sS -X POST "$BASE/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"annalward"}' | jq -r '.data.token')
echo "guest token: ${TOKEN:0:12}..."

Step 3 — GET /me

Who the token belongs to and what it can spend. sdk.js documents the shape as {subject_type, subject_id, credits, profile?}. The page treats the caller as signed in only when subject_type is "user", shows credits as the balance (1 credit = $0.0001, so it divides by 10 000 for the dollar figure), and compares it with the estimate in step 4 before it lets you run.

api GET /me | jq '.data // .error'

Step 4 — build the input, then POST /estimate

First a small makeInput that does what buildInput does: task, then code as the paste the page would send, facts as a compact JSON string, the note when it is not blank, and — for the fix lane only — the audit_handoff string when the file carries one. It reads the file build-check.js (or handoff.js) printed. The samples build the audit input of the renewals example from renewals-check.json; step 6 reuses makeInput for the payouts (fix) example. Then price it. The body of /estimate is the same input you will send to /run; no job is created and nothing is charged — /estimate is free. From the response the page reads:

FieldMeaning
hold_creditsA reservation, not a price: the worst case the platform holds while the run is in flight. The page shows it as “reserves N credits ($...) for the audit run” and adds that you are charged only for what the run actually uses, usually far less. What you pay is the job’s charged_credits after settlement (step 5). A balance below the hold is allowed, but the page warns that the reply may be cut short (truncated: true).
min_creditsThe smallest balance that can start a run. Below it the page disables the run button and names the shortfall, because the call would be a 402.
sponsor_enabledWhen true, a caller who is not signed in may run; otherwise the page asks them to sign in.
warningsInput warnings. They do not refuse the call; the page shows them next to the price (“input warnings: ...”), so check them before you run.

The page re-prices whenever the paste, the note or the lane change. The amounts grow with the size of code and facts, so read them from the call rather than hard-coding them.

# renewals-check.json: printed by build-check.js (see "The facts string").
# (.facts | tojson) turns the facts into ONE compact string, as JSON.stringify does.
BODY=$(jq -c --arg note "Each subscription runs as one workflow for as long as the account exists. Anything that will bite us in a year?" \
  '{task: "audit", code, facts: (.facts | tojson), note: $note}' renewals-check.json)
# No page check at hand? facts: "{}" is accepted as a string, but the reply then has no page flags to answer.
# Fix lane after an audit: read handoff.js's output instead and add its string:
#   '{task: "fix", code, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)'

api POST /estimate -d "$BODY" \
  | jq '.error // (.data | {hold_credits, min_credits, sponsor_enabled, warnings})'

Step 5 — POST /run, then poll the job (audit)

Send the same input to /run with an Idempotency-Key header, using a personal token. Its data carries a job_id. Poll GET /jobs/{job_id} the way sdk.js waitForJob does: every second until status is succeeded or failed, giving up on the client side after 180 seconds. On the finished job, read:

Idempotency key. Resending a request with the same key must not start a second paid run, so derive the key from the input, not from the clock — and include the lane, so an audit and a fix run of the same paste never share a key. The page uses annalward:<lane>:<inputHash>:a<attempt> (app.js run), where <lane> is audit or fix, <inputHash> is a 32-bit djb2 hash of JSON.stringify(input) in hex, a dash, and the string’s length in hex (app.js inputHash, over the input without retry_note), and <attempt> is 1 for a normal run and 2 for the automatic reformat retry (which also adds retry_note). For the two worked examples that is annalward:audit:ede4d6c7-25e5:a1 (renewals audit) and annalward:fix:a9ad0833-1e49:a1 (payouts fix); the fix run of the renewals audit handed on (above) is annalward:fix:6bf3d7d4-2d57:a1. The format is the page’s convention, not a requirement: any stable, unique string does. The samples below use the lane and a SHA-256 prefix.

The page itself uses /run-stream (step 6), not /run; both take the same body and header.

KEY="annalward:audit:$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-16):a1"

JOB=$(api POST /run -H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')

for i in $(seq 180); do                       # 1 s interval, 180 s client-side limit
  J=$(api GET "/jobs/$JOB")
  S=$(printf '%s' "$J" | jq -r '.data.status')
  if [ "$S" = "succeeded" ] || [ "$S" = "failed" ]; then break; fi
  sleep 1
done

printf '%s' "$J" | jq '.data | {status, truncated, charged_credits, error}'
printf '%s' "$J" | jq -r '.data.output.output' \
  | jq '{verdict, headline, areas: [(.areas // [])[] | {area, status}],
         findings: [(.findings // [])[] | {id, file, line, severity, edits: ((.edits // []) | length)}],
         flag_answers: [(.flag_answers // [])[] | {flag, status}]}'

Step 6 — POST /run-stream (Server-Sent Events, fix)

The alternative to step 5, and what the page uses: same kind of body, same Idempotency-Key header, one request. Run one or the other for a given input, not both. To show the second lane, the samples build the fix input of the payouts example (with its own key) and stream it; to stream the audit input instead, pass the step 4 input and key. The response is text/event-stream. sdk.js splits it into frames on a blank line, takes the event name from an event: line (default message) and concatenates the data: lines, skips frames with no data or with data that is not JSON, and handles these events:

EventDataWhat the SDK does
delta{text}Passes text to onDelta.
jobjob infoPasses it to onJob (the page uses it to advance its progress display).
donethe final payload: {job_id, status, charged_credits, output}Keeps it as the result.
pendinga payloadTreated exactly like done. If it carries a job_id and no output, poll that job as in step 5.
error{message, code, job_id}After the stream ends, raises an error with those fields.

If the response is not text/event-stream, it is an ordinary JSON envelope: the SDK returns its data (an idempotent replay returns this way) or raises the error on a non-2xx status. Handle both shapes.

From the final payload the page reads output.output (falling back to the concatenated delta text), truncated and charged_credits. Browsers receive progress ticks rather than deltas from this endpoint, so the page relies on the final payload; if the stream fails part-way, it tries to recover a partial reply from whatever delta text arrived. The samples print the verdict and headline, each returned file’s path and size, each change (function, flags and change), each verification step, each open item and each question. To put the files into the paste and re-check them the way the page’s “Put every file into the paste” does, keep the reply text and run recheck.js with the lane fix. To fix code you audited, read handoff.js’s output instead of payouts-check.json: makeInput then carries its audit_handoff.

# the fix worked example: payouts-check.json from build-check.js
# (for audited code, read handoff.js's output instead; the + (...) adds its audit_handoff)
FBODY=$(jq -c --arg note "The payout worker will not even start. Fix it. Keep the two activities as plain def functions - the ledger client is sync." \
  '{task: "fix", code, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)' \
  payouts-check.json)
FKEY="annalward:fix:$(printf '%s' "$FBODY" | shasum -a 256 | cut -c1-16):a1"

# -N turns off buffering; the whole stream lands in $STREAM
STREAM=$(api POST /run-stream -N -H "Idempotency-Key: $FKEY" -d "$FBODY")

# frames look like:
#   event: job
#   data: {...}
#
#   event: done
#   data: {"job_id":"...","status":"...","charged_credits":...,"output":{"output":"{\"task\":\"fix\",...}"}}

# the final reply text, from the done (or pending) frame:
REPLY=$(printf '%s\n' "$STREAM" \
  | awk '/^event:/{ev=$2} /^data:/ && (ev=="done" || ev=="pending"){sub(/^data: */,""); print}' \
  | jq -r '.output.output')
# an idempotent replay answers with a plain JSON envelope instead: REPLY=$(printf '%s' "$STREAM" | jq -r '.data.output.output')
printf '%s' "$REPLY" | jq '{verdict, headline,
  files: [(.files // [])[] | {path, characters: (.content | length)}],
  changes: [(.changes // [])[] | {function, flags, change}], verify, open, questions}'

What the page does that you now do

Or skip all of this and use Annalward in the browser: the check, the flags, the workflow, activity-call, activity, compensation and Worker inventories, putting an edit or the fixed files back into the paste, and the Markdown and CSV exports are free.