Run your own RL code or a prepared recipe
You can start with your own training command and optional data. You do not need
to select a catalog environment. To show reported RL task progress, add the
optional rl metadata to a normal run:
def launch_custom_rl(
client, *, image, source_asset_id, budget, stable_run_id,
model_label, planned_tasks,
):
if not isinstance(stable_run_id, str) or not stable_run_id:
raise ValueError("stable_run_id must be a nonempty stable key for this run")
if budget is None:
raise ValueError("budget must be an explicit spending limit for this run")
return client.run(
image=image,
source_asset_id=source_asset_id,
command=["python", "train.py"],
outputs={"results": "outputs"},
budget=budget,
compute_class="accelerator",
idempotency_key=stable_run_id,
extra={
"rl": {
"schema_version": 1,
"environment_id": "custom",
"mode": "train",
"model": model_label,
"planned_tasks": planned_tasks,
}
},
)
Use an imported source asset containing your train.py, a compatible runtime
image and an explicit spending limit. The command must write final results
under outputs and write and load its own checkpoint state. Adjust the command
and output path to match your project.
The metadata describes your experiment. Your code implements the trainer,
model, task limit and task-event reporting. Use mode="evaluate" for evaluation
without training. Custom runs cannot set rl.recipe or claim managed recipe
validation. A completed command without task events has no reported RL score.
Use a prepared recipe
RL recipes describe supported evaluation and training runs. Recipe availability reflects operator qualification and current launch settings. Admission and capacity are checked again at launch, so an available recipe can still fail to launch.
Start by finding the recipe you intend to use and checking its current status:
import nodus
def find_recipe(client):
recipes = client.rl.list_recipes()
recipe = next(
(
item for item in recipes
if item.id == "reasoning-gym-leg-counting"
),
None,
)
if recipe is None:
raise RuntimeError("The requested RL recipe is not offered")
if not recipe.available:
reason = recipe.unavailable_reason or "No reason was provided"
raise RuntimeError(f"RL recipe is unavailable: {reason}")
return recipe
Build the configuration explicitly, then ask the server to normalize and review it. Preview does not launch a workload.
def prepare_run(client, recipe):
configuration = {
"recipe_id": recipe.id,
"recipe_version": recipe.version,
"mode": "evaluate",
"evaluation_tasks": 16,
"training_steps": 20,
"max_cost_usd": 5,
"seed": 42,
"include_traces": False,
}
preview = client.rl.preview(configuration)
print(preview.estimate)
print(preview.phases)
print(preview.outputs)
if not preview.launchable:
raise RuntimeError(
"RL run cannot launch: " + ", ".join(preview.blocking_reasons)
)
return preview
Inspect the normalized configuration, phases, outputs, and estimate before launching. The server returns a review token for that exact preview. A changed configuration needs a new preview and review token.
Launch only after your application or a person has accepted the preview. Use a stable idempotency key for one intended run and store it with your own run record.
def launch_run(client, preview, stable_run_id):
workload = client.rl.launch(
configuration=preview.configuration,
review_token=preview.review_token,
idempotency_key=stable_run_id,
)
print(workload.id)
return workload
launch() requires all three values. It does not select a recipe, fill in a
configuration, or generate an idempotency key. This makes the paid action
explicit and lets an application retry safely.
If a timeout or connection failure leaves the launch outcome unclear, retry
the same configuration and review token with the same idempotency key. A new
key can create another paid run. Reusing one key with a different request
raises IdempotencyConflictError.
Normal SDK exceptions apply:
def launch_with_error_handling(client, preview, stable_run_id):
try:
return client.rl.launch(
configuration=preview.configuration,
review_token=preview.review_token,
idempotency_key=stable_run_id,
)
except nodus.IdempotencyConflictError:
raise RuntimeError("The idempotency key belongs to a different request")
except nodus.NodusError as error:
raise RuntimeError(f"RL launch failed: {error}") from error
The asynchronous client has the same flow:
async def prepare_async(client, configuration):
recipes = await client.rl.list_recipes()
recipe = next(
(item for item in recipes if item.id == configuration["recipe_id"]),
None,
)
if recipe is None or not recipe.available:
raise RuntimeError("The requested RL recipe is unavailable")
preview = await client.rl.preview(configuration)
if not preview.launchable:
raise RuntimeError(
"RL run cannot launch: " + ", ".join(preview.blocking_reasons)
)
return preview
async def launch_async(client, preview, stable_run_id):
return await client.rl.launch(
configuration=preview.configuration,
review_token=preview.review_token,
idempotency_key=stable_run_id,
)
Read task evidence and grading receipts
Use client.rl.events() for reported RL task evidence. workload.events()
returns separate workload lifecycle events.
def read_available_tasks(client, workload_id, cursor=None):
while True:
page = client.rl.events(workload_id, after=cursor, limit=100)
for row in page.events:
event = row.event
print(event.task_id, event.outcome, event.reward)
if page.truncated or page.dropped_events:
print("Task evidence is partial", page.dropped_events)
cursor = page.next_cursor
if not page.has_more:
return cursor
Pass next_cursor unchanged as after. Keep the returned cursor for the next
read. A row ID is a separate string identity and must not be used as a cursor.
Recovery can repeat an application event identity in a later execution
generation. Keep each row and inspect its generation instead of deduplicating
across generations.
An empty page or has_more=False means no more rows were available in that
snapshot. Continue polling while the workload is active and read again after
observing its terminal status. Task events are application-reported evidence.
They do not independently prove model quality or workload completion.
For a workload with a server-admitted private grading plan, request receipts for the explicit revision you submitted:
def read_grading(client, workload_id, revision):
results = client.rl.grading_results(workload_id, revision=revision)
for receipt in results.receipts:
print(receipt.task_id, receipt.state, receipt.reward)
return results
async def read_evidence_async(client, workload_id, revision, cursor=None):
page = await client.rl.events(workload_id, after=cursor)
grading = await client.rl.grading_results(workload_id, revision=revision)
return page, grading
Receipt rewards are None when absent and 0 for a measured zero. An
infrastructure failure does not supply a model reward. Cleanup fields describe
the grading attempts in the selected revision and do not establish parent
cleanup or final billing. A missing grading plan raises NotFoundError.
Reading receipts does not enable private grading for a workload.