Repeat Until Accepted
A generation model does not always get it right the first time: the product’s label comes out different, the
face is not quite the person, the logo has the wrong colour, the copy is off brand. Repeat Until Accepted
(workflow/repeat) turns “generate, check, correct” into one step of a workflow. It runs a body — another
published workflow of the workspace — up to max_attempts times. Each attempt produces a candidate and judges it;
the first attempt the body accepts is delivered, and every attempt that is not accepted passes what it found to the
next one, so that the next generation corrects it instead of starting again from zero.
The engine knows nothing about images, products or brands. What is judged, and how, lives in the body: a Decide with a Model question, an Image Analysis comparison, a measurement fed to a Quality Gate. The step only counts the attempts, carries the feedback forward, chooses what to deliver and reports what happened.
This page is the contract for integrators and agents. The knowledge base page Repeat until accepted explains the idea with diagrams and complete examples.
The step
Section titled “The step”{ "id": "packshot", "type": "workflow/repeat", "label": "Packshot until on product", "parameters": { "workflow": "wf_7c9e6679742540de944be07fc1f90ae7", "max_attempts": 3, "on_exhausted": "best" } }| Parameter | Meaning | |
|---|---|---|
workflow |
required | The body: the wf_ ID of a workflow of the same workspace, published first. The internal numeric workflowId is rejected, as for workflow/sub. |
max_attempts |
1–5, default 3 | How many times the body may run. |
on_exhausted |
best (default), discard, fail |
What the step does when no attempt is accepted (see below). |
minimum_score_to_deliver |
0–1, optional | With best only: below this score the best attempt is discarded instead of delivered. |
Inputs. The step’s inputs are the body’s inputs, by their names, except the three the step fills itself:
attempt_number, previous_feedback and previous_candidate. Every attempt receives the same values on the other
inputs. Read the body’s inputs from its interface (GET /api/v1/workflows/{wf_id}, MCP get_workflow) and
connect them by name; validation reports a connection to a port the body does not have.
Outputs (fixed, whatever the body):
| Output | Type | Value |
|---|---|---|
candidate |
the body’s candidate type |
The delivered result. Rejected (absent, reason ATTEMPTS_DISCARDED) when nothing is delivered, so every step that reads it is skipped. |
accepted |
boolean | true only when an attempt was accepted. A best attempt delivered without acceptance is false. |
score |
number | The score of the delivered attempt; absent when nothing is delivered. |
selected_attempt |
number | Which attempt was delivered (1 first); absent when nothing is delivered. |
disposition |
text | accepted, best_unaccepted or discarded. |
report |
JSON | madoo.attempts-report/v1: every attempt, see below. |
Route on accepted or disposition with the free decision nodes (Filter, Predicate, Select by Key): for example,
publish an accepted packshot and send a best_unaccepted one to review.
The body
Section titled “The body”The body is an ordinary workflow you can open, run and test on its own. To be a body it returns four outputs, each with one value:
| Body output | Node | Meaning |
|---|---|---|
candidate |
any output node | The result of the attempt: an image, a video, a text, a document… |
accepted |
Output JSON holding true or false |
The body’s decision. |
score |
Output JSON holding a number from 0 to 1 | The grade, used to choose the best attempt when none is accepted. |
feedback |
Output Text | What the next attempt must correct. Required whenever an attempt is not accepted and another remains. |
It may declare any of these inputs, which the step fills:
| Body input | Node | Value |
|---|---|---|
attempt_number |
Number Input | 1, 2, 3… |
previous_feedback |
Text Input, optional | The feedback of the attempt before; not provided on attempt 1. Wire it to the generator’s instructions input. |
previous_candidate |
an optional input of the candidate’s type | The candidate of the attempt before; not provided on attempt 1. |
“Not provided” is the ordinary absence of an optional input: the steps of the body still run without it, so the
first attempt is simply the plain generation. Every text-driven generator (images, video, audio, 3D) has an
instructions input whose text is added after the prompt it composes, so the feedback steers the next attempt
without rewriting the prompt.
A typical body: Product Photography (with the product’s reference photos) → Decide with a Model (yes_no “the
label and the shape are those of the reference”, score “how faithful”) and Image Analysis (what differs) → Text
Template that writes the correction → feedback. The accepted answer goes to accepted and the score’s
probability to score through Output JSON.
The body runs at the version it had when the step started: a body published again during the run does not change the attempts still to come.
When no attempt is accepted
Section titled “When no attempt is accepted”on_exhausted |
Delivered | disposition |
The run |
|---|---|---|---|
best |
The attempt with the highest score (the earliest on a tie). With minimum_score_to_deliver and a best score below it, nothing. |
best_unaccepted (or discarded) |
continues |
discard |
Nothing: candidate is rejected and its branch skipped at no cost. |
discarded |
continues |
fail |
Nothing: the step fails with ATTEMPTS_EXHAUSTED. |
— | fails |
A discarded candidate is a rejection, like a Filter that rejects: steps marked accepts_rejected (aggregators,
Select by Key, Quality Gate) still receive it, so a list built per item keeps its other items.
A technical failure is not a rejection. If an attempt fails (a provider error, a timeout, a step of the body that fails), the Repeat Until Accepted step fails with that attempt’s error code and does not spend another attempt. Retries of a failing provider are the job of the step that called it; a new attempt is for a result that was produced and judged not good enough.
The report
Section titled “The report”report follows madoo.attempts-report/v1 (GET /api/v1/json-schemas, MCP get_json_schema). It is built from
the attempts’ own runs — never from text a model wrote — and carries public IDs only:
{ "schema": "madoo.attempts-report/v1", "body": { "workflow": "wf_7c9e6679742540de944be07fc1f90ae7", "version": 4 }, "policy": { "max_attempts": 3, "on_exhausted": "best", "minimum_score_to_deliver": null }, "item": null, "attempts": [ { "attempt": 1, "execution": "run_3f2c…", "accepted": false, "score": 0.42, "feedback": "The label reads 'Brew' instead of 'Teà'; the spout is too short." }, { "attempt": 2, "execution": "run_9a71…", "accepted": true, "score": 0.91, "feedback": null } ], "selected_attempt": 2, "disposition": "accepted", "reason": "Attempt 2 was accepted."}item is the item of the iteration when the step repeats per item, null otherwise. Each attempt’s execution is
a run of its own: GET /api/v1/executions/{run_id}/trace shows every step of that attempt.
Watching the attempts
Section titled “Watching the attempts”The report exists once the step has settled. To follow the attempts while the step repeats, and to look at each
candidate, read the trace of the parent run: GET /api/v1/executions/{run_id}/trace (MCP get_execution_trace).
The Repeat Until Accepted node carries attempts; when it repeats per item, each item of
GET …/trace/nodes/{node_id}/iterations carries its own (MCP get_execution_trace with node_id).
"attempts": [ { "attempt": 1, "execution_id": "run_3f2c…", "status": "completed", "accepted": false, "score": 0.42, "delivered": false, "credits_used": 5.02, "feedback": "The label reads 'Brew' instead of 'Teà'; the spout is too short.", "candidate_type": "image", "trace_url": "https://…/api/v1/executions/run_3f2c…/trace", "result_url": "https://…/api/v1/executions/run_3f2c…/result", "candidate_preview_url": "https://…/api/v1/executions/run_3f2c…/outputs/candidate/preview" }, { "attempt": 2, "execution_id": "run_9a71…", "status": "running", "accepted": null, "score": null, "delivered": false, "candidate_type": null, "…": "…" }]| Field | Meaning |
|---|---|
attempt |
1 for the first attempt. |
execution_id |
The attempt’s own run. |
status |
The run’s status: pending, running, completed, failed, cancelled… |
accepted, score, feedback |
The body’s verdict, once the attempt has ended; null before. |
delivered |
true for the attempt whose candidate the step gave on. With discarded, none. |
credits_used |
What the attempt cost. |
problem |
Why an attempt that ended has no verdict: its body broke the contract (the reason of ATTEMPT_CONTRACT_INVALID). |
candidate_type |
The type of the attempt’s candidate, when it has one. |
trace_url, result_url |
The attempt’s trace, and its result (the candidate as by_key.candidate). |
candidate_preview_url |
A picture of the candidate — an image fitted to the screen, a sheet of a video’s frames, a page of a PDF — when it is not text or JSON. Read-only and free, as for any output’s preview. |
The other nodes have no attempts field. In the Madoo app the same attempts are shown in the step’s execution
panel of the editor and in the run’s pipeline on the Executions page: one card per attempt with its candidate, its
verdict and its correction, the delivered one marked, and a full-screen viewer to go through the candidates.
Inside an iteration
Section titled “Inside an iteration”Like a sub-workflow, a Repeat Until Accepted step inside an iterated branch runs per item: every product of a
catalogue, every language, every scene gets its own attempts, its own delivered candidate and its own report, and
an item that fails or is discarded does not touch the others. The limit of children per step counts every attempt:
a known list of items × max_attempts above it is refused at estimate and submission
(ITERATED_SUBWORKFLOW_LIMIT_EXCEEDED), before anything is charged.
Credits
Section titled “Credits”The estimate and the credit hold count the worst case: the body’s cost × max_attempts (× the items, inside
an iteration). Only the attempts that ran are charged: the step’s cost is the sum of its attempts, and what the
hold reserved for attempts that never ran is released with the rest of the hold when the run ends. For a list known only during the run, the worst case is reserved when
the list is known, before the first attempt starts; if the credits are not enough the step fails then, without
running any attempt.
Because the worst case must be reserved, the body must have an exact cost and call no other workflow: otherwise
the estimate and the submission refuse it with REPEAT_COST_UNBOUNDED.
Errors
Section titled “Errors”Authoring (create, update, validate, publish; listed in §2 of the authoring guide):
repeat_settings_invalid, repeat_reserved_input, repeat_body_contract_invalid and the warning
repeat_feedback_unused.
At run time, as the step’s error code:
| Code | When |
|---|---|
ATTEMPT_CONTRACT_INVALID |
An attempt returned no candidate, an accepted that is not a boolean, or a score outside 0–1. |
ATTEMPT_FEEDBACK_MISSING |
An attempt was not accepted, another one remains, and the body wrote no feedback. |
ATTEMPTS_EXHAUSTED |
No attempt was accepted and on_exhausted is fail. |
REPEAT_COST_UNBOUNDED |
The body’s cost is not exact, or it calls another workflow (estimate and submission). |
| the attempt’s own code | An attempt failed for a technical reason. |