> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tilion.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Workflows

> POST /v1/workflow runs a multi-step browser job in one call. It has two modes:

`POST /v1/workflow` runs a multi-step browser job in one call. It has two modes:

* Prompt mode: you pass a natural-language `prompt`; the model plans it, then drives the browser and the content tools in a loop until the plan's success criteria hold, the model says it is done, or a cap is hit. This mode spends model tokens and is billed per plan call and per step.
* Recording mode: you pass a Waystone `recording` (the `automation.json` a `waystone record` session wrote) and the values for its placeholders; the recorded steps are replayed in your session's browser with no model, zero tokens, and a step-by-step report. This is the deterministic way to automate a flow you can perform once by hand.

Exactly one of `prompt` or `recording` must be present.

## POST /v1/workflow

Auth: Bearer API key.

Request:

| Field | Type | Default | Mode | Meaning |
| - | - | - | - | - |
| `prompt` | string | null | prompt | the task in natural language, 1 to 8000 characters |
| `recording` | object | null | recording | a Waystone `automation.json`: the folder wrapper (with `workflow` inside), the CDP export (with `workflow` next to `run`), or a bare workflow (`name`, `start_url`, `steps`) |
| `session_id` | string | null | both | the session whose browser runs the job. Required with a recording; optional with a prompt |
| `params` | object of strings | `{}` | recording | values for the recording's `{{name}}` placeholders. A missing one is refused with 422 before the browser is touched |
| `until` | integer | null | recording | replay steps `0..until` inclusive, then stop |
| `return_markdown` | boolean | `false` | recording | include the page the replay ended on as markdown |
| `answers` | object | null | prompt | answers to the clarifications a previous `POST /v1/plan` asked |
| `max_records` | integer | null | prompt | override the plan's target count (sets the success criterion `min_records`) |
| `budget_tokens` | integer | null | prompt | a token ceiling for the run; checked after each step, so a run can end a little over it |
| `max_rows_in_response` | integer | 200 | prompt | cap on `dataset` rows returned, 1 to 5000; the full set is the run's artifact |

### Prompt mode

Response:

| Field | Type | Meaning |
| - | - | - |
| `plan` | object | the plan that was run, the same shape `POST /v1/plan` returns |
| `terminal` | string | why the run stopped: `completed` (success criteria held), `model_finished`, `step_cap`, `budget_exceeded`, `no_tools` (the model produced neither a tool call nor a finish), `no_data` (every source was blocked, empty or behind a login), `error` |
| `dataset` | object\[] | the records collected, at most `max_rows_in_response` |
| `dataset_total`, `dataset_truncated` | integer, boolean | the full count and whether `dataset` was cut |
| `trace` | object\[] | one entry per tool call: `tool`, `params`, `outcome`, and sizes such as `content_chars` |
| `steps` | integer | tool calls made |
| `tokens` | object | `in`, `out`, `total` |
| `result_text` | string | the model's closing text, often empty |

Request and answer (the `plan` object is trimmed to its top-level keys; it is the same shape as the `/v1/plan` example below):

```json theme={"dark"}
{"prompt": "Get the heading text from https://httpbin.org/html", "session_id": "sess_3d245e9c250d4713b0f633ab34f018f0",
 "max_records": 1, "budget_tokens": 4000, "max_rows_in_response": 5}
```

```json theme={"dark"}
{"plan": {"goal": "Get the heading text from https://httpbin.org/html", "task_type": "data_collection", "target": {"entity": "record", "count": null, "output_schema": ["heading_text", "url"], "constraints": ["url=https://httpbin.org/html"], "dedup_key": ["url"]}, "phases": "...", "success": {"min_records": 1, "required_fields": ["url"], "field_validity_min": 0.9, "unique_by": ["url"], "notes": "all record(s) matching url=https://httpbin.org/html"}, "estimate": {"plan_tokens": 448, "runtime_tokens": 0, "browser_seconds": 30.0, "egress_mb": 7.5, "est_usd": 0.01, "wall_clock_s": 8.0, "confidence": 0.5, "dominant_risk": "no scraper yet"}, "gaps": "...", "assumptions": "...", "clarifications": "...", "is_valid": true}, "terminal": "budget_exceeded", "dataset": [], "trace": [{"tool": "fetch_page", "params": {"url": "https://httpbin.org/html"}, "outcome": "ok", "content_chars": 3596}], "steps": 2, "tokens": {"in": 5937, "out": 97, "total": 6034}, "result_text": "", "dataset_total": 0, "dataset_truncated": false}
```

The run fetched the page in its first step and stopped at the token ceiling (6034 tokens against a budget of 4000, because the budget is checked after each step) before it emitted a record. A tiny budget is a way to cap spend on a probe; a real run needs a larger one.

Billing: one plan call plus one workflow step per tool call (2 credits each; `GET /v1/credits` has the current numbers). The run above was 2 + 2 x 2 = 6 credits. A run that ends in `terminal: "error"` bills the plan call only.

### Recording mode

How to record: run a Waystone recording session (`waystone record <url> -n <name>`), perform the task once in the browser it opens, and stop. Waystone writes a folder named after the recording with an `automation.json`; post that file's contents as `recording`. Values you typed can be turned into `{{placeholders}}` in the recording and supplied per run through `params`.

What the replay does: every step's target carries ranked selector candidates and a fingerprint of the element. For each step the page is left to settle for at least twice the time the recorder measured, the candidates are tried in order, the first that matches one visible element wins (several matches are ranked by fingerprint), and the action is delivered with real pointer and keyboard input. A step whose candidates all miss within 10 s fails the run and reports the candidate kinds it tried. Dialogs the page raises are accepted. Recorded secrets are scrubbed from the report.

Supported step types: `navigate`, `click`, `dblclick`, `type`, `press`, `select`, `check`, `set`, `hover`, `scroll`, `new-tab`, `switch-tab`, `dialog`, `wait`. Not supported, and reported rather than crashed: `drag`, `upload`, `print`, and any target located on a canvas (`terminal: "unsupported"`). A recording may have at most 500 steps.

Response:

| Field | Type | Meaning |
| - | - | - |
| `terminal` | string | `done`, `step_failed`, `unsupported` |
| `steps_run` | integer | steps that completed |
| `steps[]` | object\[] | one per step attempted: `index`, `type`, `ok`, `resolved_by` (`kind`, `value`, `score`, and `fallback` when a programmatic click or value set was needed; null for steps with no target), `waited_ms` (settling and locating time inside the step), `url` (after the step) |
| `failed_step` | object or null | `index`, `type`, `reason`, `tried` (the candidate kinds attempted) |
| `final_url` | string | where the tab ended |
| `markdown` | string or null | the final page as markdown when `return_markdown` is true |
| `name` | string | the recording's name |
| `tokens` | object | always `{"in": 0, "out": 0, "total": 0}` |
| `plan`, `dataset`, `trace`, `dataset_total`, `dataset_truncated` | null, \[], \[], 0, false | present so a prompt-mode consumer can read either answer |

A `wikipedia_search` recording (searches Wikipedia for a topic, waits for the article, opens its Talk page), abbreviated to its first step; `markdown` trimmed:

```json theme={"dark"}
{"recording": {"waystone": 2, "name": "wikipedia_search", "start_url": "https://en.wikipedia.org/wiki/Main_Page", "params": ["query"],
               "workflow": {"name": "wikipedia_search", "start_url": "https://en.wikipedia.org/wiki/Main_Page",
                            "steps": [{"index": 0, "type": "navigate", "url": "https://en.wikipedia.org/wiki/Main_Page", "settle_ms": 850}, "...[5 more steps: type {{query}}, press Enter, navigate, wait, click]"]}},
 "params": {"query": "Python (programming language)"},
 "session_id": "sess_3d245e9c250d4713b0f633ab34f018f0", "return_markdown": true}
```

```json theme={"dark"}
{"terminal": "done", "steps_run": 6, "steps": [{"index": 0, "type": "navigate", "ok": true, "resolved_by": null, "waited_ms": 0, "url": "https://en.wikipedia.org/wiki/Main_Page"}, {"index": 1, "type": "type", "ok": true, "resolved_by": {"kind": "css", "value": "input[name=\"search\"]", "score": 0.8}, "waited_ms": 303, "url": "https://en.wikipedia.org/wiki/Main_Page"}, {"index": 2, "type": "press", "ok": true, "resolved_by": null, "waited_ms": 445, "url": "https://en.wikipedia.org/wiki/Python_(programming_language)"}, {"index": 3, "type": "navigate", "ok": true, "resolved_by": null, "waited_ms": 856, "url": "https://en.wikipedia.org/wiki/Python_(programming_language)"}, {"index": 4, "type": "wait", "ok": true, "resolved_by": {"kind": "css", "value": "#firstHeading", "score": 0.9}, "waited_ms": 711, "url": "https://en.wikipedia.org/wiki/Python_(programming_language)"}, {"index": 5, "type": "click", "ok": true, "resolved_by": {"kind": "role", "value": "link:Talk", "score": 0.9166666666666666}, "waited_ms": 303, "url": "https://en.wikipedia.org/wiki/Talk:Python_(programming_language)"}], "failed_step": null, "final_url": "https://en.wikipedia.org/wiki/Talk:Python_(programming_language)", "markdown": "# Talk:Python (programming language)\n\n...", "tokens": {"in": 0, "out": 0, "total": 0}, "plan": null, "dataset": [], "trace": [], "name": "wikipedia_search", "dataset_total": 0, "dataset_truncated": false}
```

An unsupported step (a bare workflow with one `drag` step):

```json theme={"dark"}
{"terminal": "unsupported", "steps_run": 0, "steps": [{"index": 0, "type": "drag", "ok": false, "resolved_by": null, "waited_ms": 303, "url": "https://example.com/"}], "failed_step": {"index": 0, "type": "drag", "reason": "drag steps are not supported by workflow replay", "tried": ["css"]}, "final_url": "https://example.com/", "markdown": null, "tokens": {"in": 0, "out": 0, "total": 0}, "plan": null, "dataset": [], "trace": [], "name": "workflow", "dataset_total": 0, "dataset_truncated": false}
```

Errors (both modes):

| Status | `detail` | When |
| - | - | - |
| 400 | `unsafe url: ...` | prompt mode: the model chose a URL on a non-public host; the fetch is refused |
| 402 | `insufficient_credits`, `quota_exceeded` | before any model call |
| 404 | `No such session` | unknown session or not yours |
| 409 | `session browser is not alive` | the session's browser died |
| 410 | the session's closing record | the session has ended |
| 422 | validation list in `detail` | Neither or both of `prompt` and `recording` were given; the validation message says `pass exactly one of prompt or recording`. |
| 422 | `a recording replays on a session's browser: pass session_id` | a recording without `session_id` |
| 422 | `recording: ...` | the recording cannot be run: no steps, no `start_url`, more than 500 steps, an unknown step type, a target with no candidates |
| 422 | `missing params: <names>` | a `{{placeholder}}` has no value in `params`: `{"detail": "missing params: query"}` |
| 429 | `rate_limited` | the per-key drive limit |

A failed or unsupported step is not an HTTP error: the answer is 200 with `terminal` and `failed_step` set, and the steps that ran are reported.

Notes:

* Timing: the Wikipedia recording replays in 13 to 15 s; most of it is the page's own load and settle time.
* Billing: one workflow step per step that ran (2 credits each), no plan call, no tokens. The six-step run above was 12 credits; the `drag` run was free (nothing ran).
* `until` lets you replay part of a recording, for example to reach a logged-in page and then take over with `/v1/agent` tasks on the same session.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.