The extract step
An extract step reads one document and returns a typed value the rest of the
ruleset can use. The document may be a PDF, a scanned page, a photo, a plain text file,
an HTML file or an email. A model reads the document and fills in a target schema
you declare; the platform checks the answer against that schema, records what it did
in the run's trace, and hands only the checked value to the next step.
This page states what each field does, how pages and model calls are counted, what the confidence figure is and is not, and what extraction costs. Extraction is an aid to a rule, not an oracle: the worked example near the end shows the pattern the platform is designed for, which is to compare what a document says with what the sending system said, and to let a disagreement decide.
A complete step
{
"id": "read-receipt",
"type": "extract",
"document": "documents.receipt[0]",
"reading": "auto",
"ai": {
"model": "<an alias from the editor's model list>",
"prompt": { "mode": "generated" },
"targetSchema": {
"type": "object",
"properties": {
"total": {
"type": "number",
"scale": 2,
"description": "The total amount paid, including VAT"
},
"issuedOn": { "type": "string", "format": "date" }
},
"required": ["total"]
},
"retries": 1,
"plausibility": [
{ "name": "totalPositive", "condition": "extracted.total > 0" }
],
"cache": { "mode": "reuse" }
},
"output": "extracted",
"onError": { "mode": "hard" }
}
Every field except reading, ai.retries, ai.plausibility, ai.cache, description and
onError is required. The optional fields have the defaults described below; an absent
onError means a hard failure, as for any step.
Fields
document
An expression that names exactly one document, usually documents.<role>[0] for a role
declared in documentInputs. An expression that yields no document, or more than one,
fails the step.
The documents a role accepts decide what the editor can check before a run:
- A role that accepts a file type extraction cannot read (JSON, for example) is refused when the ruleset is saved, in every mode. Remove the type from the role.
- A role that accepts only XML raises a warning: an extract step does not read XML.
Use an
xmlTransformstep for structured documents. If an XML document reaches an extract step anyway, the step fails withEXTRACT_DOCUMENT_XMLbefore anything is read, counted or sent to a model.
The readable media types
| Media type | What it is | How it is read |
|---|---|---|
application/pdf | A PDF, with or without a text layer | Page by page, by the text layer or by OCR (see reading) |
image/png, image/jpeg | A photo or a scan | Always by OCR |
image/tiff | A scan | Always by OCR. A TIFF with more than one page is refused (see below) |
text/plain | Plain text | As text |
text/html | An HTML document | As the visible text a browser would show |
message/rfc822 | An email (.eml) | The body only (see below) |
A TIFF with several pages is refused
The OCR service reads only the first page of a multi-page TIFF and reports no error, so
the other pages would be lost silently. Rather than send the file whole, the step fails
with EXTRACT_DOCUMENT_UNREADABLE and the detail code EXTRACT_UNREADABLE_TIFF_MULTI_PAGE. A single-page TIFF is
read normally. To extract from a multi-page scan, submit it as a PDF, or submit each page
as its own document. Splitting a TIFF on the platform is not supported yet.
An email contributes its body, nothing else
For an .eml file the step reads the message's HTML body when it has any HTML part, and its
plain-text body otherwise. Plain-text parts of a message that also has an HTML part are read
together with it. The HTML goes through the same visible-text reading as an HTML document, so
hidden text in the HTML part of an email stops the step even when a clean plain-text part is
also present. Nothing else of the message is read:
- headers, the subject and addresses are never shown to the model;
- attachments are never opened. Submit an attachment you want extracted as its own document in a role of its own;
- an attached message (
message/rfc822inside the email) is not parsed, only counted as an attachment; - a message with more than 64 MIME parts is refused: the run finishes with
DOCUMENT_PARSE_FAILEDbefore any step starts, so noonErrorfallback applies.
reading
How the pages of the document are turned into text before a model sees them. Absent means
auto.
| Value | Meaning |
|---|---|
auto | The platform decides page by page, as described below |
textLayer | Read only the text the file already carries. A PDF page with no text at all fails the step with EXTRACT_DOCUMENT_UNREADABLE. On a searchable scan, the invisible text layer of the scanner is read as text and fails the step like any invisible text (see the scanned-document exception) |
ocr | Read every page of a PDF as an image |
A forced mode has no effect on text, HTML or email. Those are read as text whatever
reading says, and an image is always read by OCR. If a step sets reading on a role that
accepts only text, HTML and email documents, the editor warns that the setting does nothing.
How auto decides for a PDF page. Any doubt resolves to OCR, because a needless OCR
call costs a few cents while a wrong text layer costs a wrong extraction:
- A page whose text is drawn invisibly (as a scanner's hidden text layer is) under an image covering most of the page is read by OCR, and the invisible layer is discarded.
- A page with fewer than 100 readable characters is read by OCR.
- A page whose text layer looks garbled (more than 15 percent of its characters unusable) is read by OCR.
- Otherwise the text layer is used.
| Constant | Value |
|---|---|
| Fewest readable characters for a text layer to be trusted | 100 |
| Largest share of unusable characters in a trusted text layer | 0.15 |
| Share of the page an image must cover to count as filling it | 0.6 |
| Share of characters drawn invisibly that counts as an invisible layer | 0.5 |
These are platform constants and are not tunable by an author: if a ruleset could move them,
the same document would be read differently under two rulesets. They were tuned on a
synthetic collection of documents, not on real customer documents. Expect some real
documents to route differently from how you would; when one does, force the mode with
reading. The route taken for every page, and the figures that decided it, are recorded in
the trace.
A document in which no readable character can be found at all fails the step with
EXTRACT_DOCUMENT_UNREADABLE. That code is raised for other reasons too. The run's failure
carries the reason as a detail code, so the trace read lists it among the failure's detail
codes and the console shows the sentence for it. Once the raw content of a run has been
purged, the detail codes are gone and the console shows the general sentence instead. The
reasons and their detail codes are:
EXTRACT_UNREADABLE_NO_READABLE_CHARACTERS: no page is read by OCR and the text of the pages holds no readable character at all.EXTRACT_UNREADABLE_TEXT_LAYER_EMPTY:readingistextLayerand at least one page carries no text.EXTRACT_UNREADABLE_PAGES_TRUNCATED: the document has more pages than the platform will read. A partial reading is never extracted from, so the step fails rather than work on the first pages only.EXTRACT_UNREADABLE_NO_READING: the platform holds no reading of the document, so there is nothing to extract from.EXTRACT_UNREADABLE_TIFF_MULTI_PAGE: a TIFF with more than one page (see above).EXTRACT_UNREADABLE_OCR_MEDIA_TYPE: a PDF page that needs OCR reached an OCR alias that accepts images only (see OCR).EXTRACT_UNREADABLE_OCR_PAGES_MISSING: the OCR model returned no result for a page it was asked to read. The step fails rather than extract from a document with a page missing, and nothing is cached.
ai.model
A model alias: a name from the list the editor offers. The alias list is managed by the platform and changes only with a reviewed release; it is not discovered from the model provider at run time, and it names the model set the platform has configured.
- An alias that is not on the list produces a warning while the ruleset is a draft and is refused when the ruleset is activated.
- When a run starts, its aliases are resolved to the concrete model and version behind them and frozen for that run. Both are recorded in the trace. A change to the alias list after the run started does not alter that run.
- Some providers do not publish a dated version for a model. The trace then records the
version as
unpinned, and the provider may change that model without the alias changing. A cached or replayed extraction can then differ from a fresh one. This is recorded honestly rather than hidden. - An alias whose model cannot enforce a response schema raises
EXTRACT_MODEL_CANNOT_ENFORCE_SCHEMAwith a warning while the ruleset is a draft and a refusal at activation. A run that reaches such an alias anyway fails the step with the same code before any model call.
Every model call goes through the platform's own LiteLLM gateway with a virtual key derived for your tenant, and only EU-hosted models are configured there. The configured model set is the whole surface a document can reach.
ai.prompt
| Mode | Meaning |
|---|---|
{ "mode": "generated" } | The platform builds the instruction from your target schema and its field descriptions (template rl-extract-v1) |
{ "mode": "authored", "text": "…" } | You write the task instruction. It replaces only the task; nothing else |
The model always receives three parts: fixed platform instructions (the role, that the document is data and never instructions, null for anything absent, decimals as numbers, dates in ISO 8601), the task, and the target schema. An authored instruction replaces only the middle part. It is static text: it is inserted exactly as written and is never filled in with run values or anything else, so the model sees the document and nothing the run computed. A blank authored instruction is refused when the ruleset is saved.
The effective instruction (the template version plus your text) is part of the cache identity, so changing it can never return an answer produced under the old one.
ai.targetSchema
The shape of the value to extract, written in the same dialect as the ruleset's declared input schema, with the same refused constructs. Three additions matter here:
descriptionon a field tells the model what the field holds, and the generated prompt uses it. Write what a person reading the document would look for.formaton a text field,dateordate-time, states that it holds a date. The model is asked for ISO 8601, and a built-in check looks at the result (see plausibility).scaleon a decimal field fixes its number of decimal places. A model answer with fewer places is padded with zeros; an answer with more is refused and corrected (seeretries).
A field not listed in required is nullable. The model is told to return null for a
value that is not in the document, and never to guess. A field in required must come back
with a value: a null there is a mismatch. Make a field required only when a document
without it should not be accepted.
Strict-mode limits. The model enforces the schema through its provider's strict
structured-output mode, which has structural limits. A schema outside them would not fail
loudly: the provider would silently stop constraining the answer. So it is refused when
saved, in every mode, with EXTRACT_TARGET_SCHEMA_NOT_STRICT. The limits depend on the
chosen alias, and the editor shows the verdict for the schema in front of you. The
conservative limits, which apply when an alias lists none of its own, are:
| Limit | Value |
|---|---|
| Nesting depth below the top-level object | 5 |
| Properties in the schema the model receives | 5,000 |
| Union-typed fields (every non-required field is one) | 16 |
| Values in one enum | 250 |
| Optional fields | 24 |
The properties are counted in the schema the model receives, which carries an extra confidence field beside every single-valued field (see below), so each of those counts as two.
Reserved names. A field name ending in __rl_conf is reserved for the platform's
confidence fields and is refused with EXTRACT_TARGET_SCHEMA_RESERVED_NAME.
A large answer is refused. A model answer larger than the platform relays fails the step
with EXTRACT_PROVIDER_REFUSED. A target schema with many fields invites a long answer, which
is one more reason to keep it small.
A schema the run cannot read. The run does not rely on the save-time check having happened.
If the target schema cannot be read in this dialect, the step fails with
STEP_EXECUTION_FAILED and the reason target-schema-unconvertible before the document is
opened, counted or sent anywhere, so nothing is spent.
ai.retries
How many times the model may be sent a corrective follow-up after an answer that does not match the target schema. A whole number from 0 to 2; absent means 1.
The follow-up lists the fields that failed and the kind of fault in fixed sentences ("the
field is required and must not be null", "the number has more decimal places than the schema
allows"). It never repeats an extracted value or any text from the document. If the last
answer still does not match, the step fails with EXTRACT_SCHEMA_MISMATCH; an answer that
does not match is never used as the step's output.
Each follow-up is a model call. The worst case for one execution of the step is one call, plus
the retries, plus one OCR call when the document may need OCR. If that exceeds the number of
model calls the ruleset allows a run, the editor warns while the ruleset is a draft and refuses
it at activation, with EXTRACT_RETRIES_EXCEED_MODEL_CALLS.
That check looks at one execution of one step. It does not add up the steps of a ruleset. A
ruleset with several extract steps, or an extract step inside a foreach, can pass it for every
step and still use more model calls than the run allows; the run then stops at run time with
ENVELOPE_MODEL_CALLS_EXCEEDED. When you have more than one extraction, raise modelCalls in the
envelope accordingly, or lower retries.
ai.plausibility
From 1 to 16 checks of your own on the extracted value; omit the field for none (an empty list
is refused when the ruleset is saved). Each has a unique name (a letter, then
letters, digits and underscores, at most 64 characters) and a condition: an expression that
must evaluate to true. The extracted value is available to the condition as extracted, and
nothing else is: the input, the documents and other steps are out of scope.
{ "name": "datesInOrder", "condition": "extracted.dueOn >= extracted.issuedOn" }
A condition that is not a yes-or-no value is refused when the ruleset is saved. A condition that evaluates to anything but a plain true, including an evaluation that fails, counts as a failed check.
A failed plausibility check never changes or fails the step. It is recorded in the trace and caps the confidence figure. If a rule must stop on a failed check, write that rule in a later step.
Three built-in checks always run, for every extraction, in addition to yours:
| Check | What it judges |
|---|---|
date-valid | A text field with format: date or date-time parses as that format and lies between 1970-01-01 and 2099-12-31 |
number-finite | A numeric field holds a finite number |
value-grounded | The value appears in the text the model was shown |
A failed built-in check lowers the confidence of the value it judged and is recorded. A
null value is judged by none of them.
ai.cache
Optional. Absent means { "mode": "reuse" }.
| Field | Meaning |
|---|---|
mode: "reuse" | Reuse an earlier answer to an identical request, when there is one |
mode: "bypass" | Never read the cache for this step; the fresh answer is still written, as the newest entry for its key |
ttlSeconds | Shortens how old a reused answer may be. It can never extend it: no entry is reused after the raw-content window (30 days), whatever this says. It applies to the model's answer only: OCR results are reused for the raw-content window regardless of ttlSeconds |
A request is identical when the document's bytes, the target schema, the effective
instruction, the reading mode, the concrete model and version, and (on a retry) the corrective
follow-up are all the same. The model reads the text the platform extracts from the document, not
the bytes, so two more things count: the platform's reading behaviour (the document reader, the
choice of which pages are read through OCR, and the clean-up applied to the text, each tracked by
a version), and, when any page of the document is read through OCR, the OCR model and its
version. When the platform changes how it reads documents, or the OCR model changes, an earlier
answer is not reused for the same document: the next run asks the model again. A document with
no page read through OCR does not depend on the OCR model. The cache is private to your tenant. A job submitted with the
flag bypassAiCache set to true skips the cache read for every extract step of that run, and
the editor's run panel has an "Ignore cached AI results" switch that sets it.
Test runs are kept apart: a test run may be served a production entry, and writes only test entries, so a test never feeds a production run.
Two identical requests running at the same moment both miss the cache and both reach the provider. Only a request that completed can be reused, so this duplicate cost is accepted, not prevented.
output
Required: a name for the value the step produces, any text. For an extract step the name is a
label only. Later steps do not read the value through it: they read it through the step's path,
as the comparison in the worked example reads steps["check/read-receipt"].total.
onError
Like any step. A soft onError supplies a fallback value when the step fails, with these
exceptions: the three limits described under counting below (pages, model calls, wall clock)
are never substitutable, and neither is a fault of the platform itself, such as the model
provider being unreachable or answering with a server error, which is retried by the queue.
A refusal by the provider (including a refusal by a filter in front of it) fails the step with
EXTRACT_PROVIDER_REFUSED and can be substituted. A model answer larger than the platform
relays is refused in the same way, with the same code, and can be substituted too.
What the step returns
Exactly the value the model answered, once the platform's confidence fields were taken off and the rest was checked against your target schema. Confidence, flags and provenance are decision evidence in the trace and never part of the value, the job result or the webhook payload.
Counting: pages, model calls and time
Three of the run's limits apply to extraction.
Pages
A run has a pages limit, the total number of document pages sent to extraction. A ruleset
may declare a lower one in its envelope; the platform ceiling is 100 and an undeclared
envelope inherits it. A document's page count is:
| Document | Pages counted |
|---|---|
| Its page count | |
| Image | 1 |
| Text, HTML or email body | One page per started 3,000 readable characters, at least 1 |
Readable characters are counted as characters, not bytes, after normalisation, leaving out whitespace and invisible formatting characters. An emoji is one character.
Pages are debited before any OCR or model call, so a breach never costs money: the step
stops with ENVELOPE_PAGES_EXCEEDED. The count is the same whether or not the answer later
comes from the cache.
Model calls
A run has a modelCalls limit. A call is debited after it returns, and only if it reached a
provider. A cache hit is not debited. A call is refused before it is made, with
ENVELOPE_MODEL_CALLS_EXCEEDED, when none is left. All the OCR pages of a document are read in
one OCR call. A corrective follow-up is a model call.
Time
Model latency counts against the run's wallClockMs, which the platform caps at 120 seconds.
One model call may take at most 45 seconds and one OCR call at most 60 seconds, each further
limited by the time the run has left. A call that exceeds its time fails the step with
EXTRACT_CALL_TIMEOUT.
OCR
Pages that need optical character recognition are read through the platform's OCR alias,
using the OCR route (/v1/ocr) of the LiteLLM gateway. Which OCR provider sits behind that alias is a
platform matter, and no provider is promised. What the alias can read is not entirely free of
consequence for your steps, as the bullets below say.
- Word-level confidence, text blocks and tables are used only when the OCR alias supplies them. When it does not, the extraction still works and the confidence figure is built from the remaining signals.
- A platform with no OCR alias configured still reads PDFs that have a text layer, plain
text, HTML and email. A page that is routed to OCR fails the step with
EXTRACT_OCR_UNAVAILABLE, which a softonErrorcan replace with a fallback. No model call is made for that step. - If the platform's OCR alias accepts images only, a PDF page that needs OCR fails the step with
EXTRACT_DOCUMENT_UNREADABLE(detail codeEXTRACT_UNREADABLE_OCR_MEDIA_TYPE). PNG, JPEG and single-page TIFF still work, and so does a PDF whose pages all have a usable text layer. - A provider that refuses the document, or takes too long, fails the step with
EXTRACT_PROVIDER_REFUSEDorEXTRACT_CALL_TIMEOUT. An OCR answer that is larger than the platform relays also fails the step withEXTRACT_PROVIDER_REFUSED.
Confidence: rl-confidence-v0
Every extracted value in the trace carries a confidence figure from 0 to 1, and the step carries one rollup. The figure is uncalibrated evidence for a reviewer. It is not a probability, not a percentage and not a measured accuracy: nothing on the platform has been calibrated against real outcomes. Use it to decide which extractions a person should look at, not to decide that one is correct.
It is shown in the run's trace and in the run inspector only. It is not in the job result, not in the webhook payload and not available to any expression.
For each single-valued field (each element of a list counted on its own):
- OCR component. For a value read by OCR: the lowest word confidence among the words matching the value; if no word matched, the lowest confidence of its page. Absent when the OCR alias supplied neither, or when the value did not come through OCR.
- Model component. The model's own confidence for the value, from the confidence field the platform asks for beside each field, limited to the range 0 to 1.
- Base. The lower of the components that are present.
- Caps. The base is then capped:
| Condition | Cap |
|---|---|
| The value does not appear in the document | 0.5 |
| A built-in plausibility check failed on the value | 0.5 |
| The document raised a flag above informational severity | 0.3 |
In practice a flag above informational severity stops the step before any model is called, so the last cap is a safeguard rather than a figure you will normally see.
The step's rollup is the lowest figure among its values, capped at 0.5 when one of your plausibility checks failed.
Unscored means there was no signal to build a figure from: a value read from a text layer for which the model gave no usable confidence. It is shown as Unscored. It is not 1.0, and it is not low; it is unknown. A step with no scored value has an Unscored rollup.
What the trace keeps
For each extract step the trace records the route taken for every page and what decided it, the pages and model calls counted, each model and OCR call (the concrete model and version, the instruction template version, the time taken, whether the answer came from the cache, the cost), the flags raised on the document, the plausibility results, the confidence figures, and where in the document each value was found.
The raw prompt, the raw model response and the OCR text are removed on the platform's retention schedule. The model, instruction version, confidence, flags, cost and the extracted output are kept. The extracted output is kept inline up to 256 KiB; a larger output is cut and marked as truncated, with a hash of the whole.
No flag or check in the trace shows text from the document. What the flags mean, and how far the defence against hostile documents goes, is on the page how extraction handles untrusted documents.
Cost
Every model call routes through the platform's own LiteLLM gateway with your tenant's virtual
key, to EU-hosted models only. The gateway reports each call's cost in US dollars. The platform
converts it to euro at the ECB euro foreign exchange reference rate (series
EXR.D.USD.EUR.SP00.A, US dollars per euro) that it had stored when the run started. That rate
is frozen for the run and the trace names it by its date.
Euro costs are provisional. The ECB publishes one rate per working day, and the conversion rate may change until the bill is created. A call served from the cache is recorded at zero cost. A call for which the provider reported no cost is marked as a cost anomaly rather than as free; its amount in the trace is not a measured zero.
Trying a step
The step inspector's "Try on a sample document" runs the step alone on one document you choose and shows the extracted value, the confidence, the flags and the cost. The run is recorded in the run history, flagged as a test, so you can open its full trace.
A worked example: extraction as independent evidence
A supporting document, here a receipt, is read by an extract step. A later step compares its total with the invoice total the sending system supplied. Neither value is trusted over the other: when they agree, there are two independent sources for the number, and when they disagree, the rule can route the case to a person.
{
"schemaVersion": 1,
"name": "receipt-check",
"inputSchema": {
"type": "object",
"properties": { "invoiceTotal": { "type": "number" } },
"required": ["invoiceTotal"]
},
"documentInputs": {
"receipt": {
"minItems": 0,
"maxItems": 1,
"mediaTypes": ["application/pdf", "image/png", "image/jpeg"]
}
},
"output": "check",
"outputSchema": {
"type": "object",
"properties": { "receiptMatches": { "type": "boolean" } },
"required": ["receiptMatches"]
},
"steps": [
{
"id": "check",
"type": "branch",
"cases": [
{
"when": "greaterThan(LENGTH(documents.receipt), 0)",
"steps": [
{
"id": "read-receipt",
"type": "extract",
"document": "documents.receipt[0]",
"ai": {
"model": "<an alias from the editor's model list>",
"prompt": { "mode": "generated" },
"targetSchema": {
"type": "object",
"properties": {
"total": {
"type": "number",
"scale": 2,
"description": "The total amount paid, including VAT"
}
},
"required": ["total"]
}
},
"output": "extracted",
"onError": { "mode": "hard" }
},
{
"id": "compare",
"type": "compute",
"outputs": {
"receiptMatches": "approxEqual(steps[\"check/read-receipt\"].total, input.invoiceTotal, 0.01)"
},
"onError": { "mode": "hard" }
}
]
}
],
"otherwise": [
{
"id": "compare",
"type": "compute",
"outputs": { "receiptMatches": "false" },
"onError": { "mode": "hard" }
}
],
"output": "compare",
"onError": { "mode": "hard" }
}
]
}
A ruleset has exactly one root step: here the branch named check, which holds the
extract step and the step that compares. A step inside a branch is addressed by the path of
step ids from the root, so the comparison reads the extracted value as
steps["check/read-receipt"].total. Step ids use lowercase letters, digits and hyphens only.
When no receipt is attached the branch takes its otherwise path and nothing is read.
total is required, so a receipt without a readable total fails the step instead of
comparing a missing number. approxEqual takes an explicit tolerance, here one cent.
Limits worth knowing
- Extraction can be wrong. A model can misread a document or return a plausible value that is not there. The confidence figure, the built-in checks and the comparison pattern above exist to make that visible, not to rule it out.
- Confidence is uncalibrated. See above.
- The routing thresholds were tuned on synthetic documents. Real documents may route differently; force the mode when they do.
- A multi-page TIFF is refused, not split.
- A model name the provider does not date can change under an alias. The trace says when that is the case.
- Two identical simultaneous requests are both paid for.