Skip to content

Step Actions Reference

Each step in a QA plan performs one of two action types: http_request or browser. The AI agent selects and configures these actions when building QA plans via MCP tools. This page documents the full configuration schema for both.

The http_request action sends an HTTP request and validates the response.

FieldTypeRequiredDescription
methodstringYesHTTP method: GET, POST, PUT, PATCH, DELETE, etc.
urlstringYesThe request URL. Supports template variables (e.g., {{base_url}}/api/users).
headersRecord<string, string>NoRequest headers as key-value pairs. Values support template variables. The runner sends headers exactly as written and does not auto-inject Content-Type — see Header handling.
bodyRequestBodyNoThe request body. Use the discriminated form below to send any Content-Type. Legacy shorthand (plain object → JSON; string → text) is still accepted.
authHttpAuthNoAuthentication helper. The runner builds the matching Authorization header — see Authentication.
timeoutnumberNoRequest timeout in milliseconds.
pollPollConfigNoPoll the endpoint until a condition is met.
response_body"auto" | "text" | "binary"NoHow to handle the response body. auto (default) decides from Content-Type. text forces UTF-8 decoding; binary forces binary handling.
max_response_body_sizenumberNoMaximum bytes to read from the response (default 52428800 = 50 MB). When exceeded, the response is truncated and body_truncated is set.

The runner sends every header exactly as written in the plan, and does not auto-inject Content-Type even when body is structured. This intentional design enables several QA cases that are otherwise impossible to express:

  • Negative tests — deliberately mismatching Content-Type and body to verify the server rejects with 400 / 415.
  • Vendor-specific MIME typesapplication/vnd.api+json, application/ld+json, application/problem+json, application/cloudevents+json, etc.
  • Charset variants — e.g. application/json; charset=utf-8.
  • Omitted Content-Type — verify the server’s default behavior when the header is missing.

For multipart bodies, you must declare both the boundary field on the body and a matching Content-Type: multipart/form-data; boundary=<same> header — they are not auto-synchronized.

The one exception to “headers as written” is the auth field: when set, the runner generates an Authorization header for you. If you ALSO write an explicit Authorization in headers, both Authorization headers are sent on the wire — the runner does not deduplicate. This keeps conflicts visible and lets negative tests express auth misconfiguration deliberately.

The auth field declares the request’s authentication intent in a structured form. The runner expands template variables in the credentials, builds the matching Authorization header, and the existing masking rules redact the header before it is recorded as an artifact. Compared to hand-rolling Authorization in headers, this keeps secrets readable in the plan and avoids the need to base64-encode credentials yourself.

typeFieldsResult
basicusername, passwordAuthorization: Basic <base64(username:password)> (RFC 7617)
bearertokenAuthorization: Bearer <token> (RFC 6750)

All credential fields support {{variable}} template expansion so they can live in the environment file as secrets.

Basic auth:

{
"method": "GET",
"url": "{{api_base_url}}/admin",
"auth": {
"type": "basic",
"username": "{{admin_user}}",
"password": "{{admin_password}}"
}
}

Bearer token:

{
"method": "GET",
"url": "{{api_base_url}}/me",
"auth": { "type": "bearer", "token": "{{access_token}}" }
}

When auth and an explicit Authorization header are both present, both are sent on the wire (see the note in Header handling).

The body field is a discriminated union on type. Pick the form that matches what you actually want on the wire.

typeUse forRequired fields
jsonPlain JSON (most REST APIs). The value is JSON.stringify-ed.value (any JSON value)
formapplication/x-www-form-urlencoded form posts (login forms, OAuth token exchange).fields: Record<string, string>
multipartmultipart/form-data for file uploads or HTML form-style submissions.fields?, files?, boundary?
textRaw text body — XML/SOAP, plain text webhooks, etc.value: string
binaryRaw bytes — image/PDF PUTs, application/octet-stream uploads.one of path or content_base64
graphqlGraphQL query envelope (sugar over JSON).query, optional variables, operationName

Plans written before the discriminated form are still accepted: a plain object becomes { type: "json", value: <object> }, and a plain string becomes { type: "text", value: <string> }. New plans should prefer the explicit form.

JSON POST:

{
"method": "POST",
"url": "{{api_base_url}}/users",
"headers": { "Content-Type": "application/json" },
"body": { "type": "json", "value": { "name": "Alice", "email": "alice@example.com" } }
}

Form-urlencoded:

{
"method": "POST",
"url": "{{api_base_url}}/login",
"headers": { "Content-Type": "application/x-www-form-urlencoded" },
"body": { "type": "form", "fields": { "username": "{{user}}", "password": "{{password}}" } }
}

Multipart file upload:

{
"method": "POST",
"url": "{{api_base_url}}/upload",
"headers": { "Content-Type": "multipart/form-data; boundary=----aqua-b" },
"body": {
"type": "multipart",
"boundary": "----aqua-b",
"fields": { "title": "report" },
"files": [
{ "name": "file", "path": "./fixtures/sample.pdf", "filename": "sample.pdf", "content_type": "application/pdf" }
]
}
}

Each file entry must supply exactly one of path (local file relative to cwd), content (inline UTF-8 text), or content_base64 (inline binary).

Raw text (XML / SOAP):

{
"method": "POST",
"url": "{{api_base_url}}/soap",
"headers": { "Content-Type": "application/xml" },
"body": { "type": "text", "value": "<envelope>...</envelope>" }
}

Raw binary (image PUT):

{
"method": "PUT",
"url": "{{api_base_url}}/avatar",
"headers": { "Content-Type": "image/png" },
"body": { "type": "binary", "path": "./fixtures/avatar.png" }
}

A binary body must supply exactly one of path or content_base64.

GraphQL:

{
"method": "POST",
"url": "{{api_base_url}}/graphql",
"headers": { "Content-Type": "application/json" },
"body": {
"type": "graphql",
"query": "query Q($id: ID!) { user(id: $id) { name } }",
"variables": { "id": "u_1" }
}
}

The HTTP driver decides whether the response is text or binary based on the response Content-Type header. text/*, application/json, application/xml, application/*+json, and application/*+xml are treated as text; everything else is binary.

For binary responses:

  • The body is not decoded as UTF-8. The raw bytes are kept and saved as a separate http_response_body artifact (downloadable from the web UI; images and PDFs are previewable).
  • json_path assertions, body_contains assertions, and extract always fail / yield nothing — use header, body_size, or body_hash assertions instead.
  • The response is read with streaming, capped at max_response_body_size. When exceeded, the body is truncated and the assertions still run against the partial bytes.

A SHA-256 hash and byte count are computed on every response (text or binary), so body_size and body_hash assertions work uniformly.

Some API endpoints require polling — repeating a request until a condition is met. When poll is specified, the request is repeated at a fixed interval until the until condition is satisfied or the timeout is reached.

FieldTypeRequiredDescription
untilobjectYesThe condition to check. Must specify one of the sub-fields below.
until.status_codenumberNoPoll until the response status code matches this value.
until.json_pathobjectNoPoll until a JSON path in the response body matches. Contains path (string) and expected (any).
interval_msnumberYesMilliseconds to wait between each poll request.
timeout_msnumberYesMaximum total time in milliseconds before the poll is considered failed.

Polling example — wait for an async job to complete:

{
"action": "http_request",
"config": {
"method": "GET",
"url": "{{api_base_url}}/jobs/{{job_id}}"
},
"poll": {
"until": {
"json_path": "$.status",
"equals": "completed"
},
"interval_ms": 2000,
"timeout_ms": 60000
}
}

The extract field captures values from the HTTP response for use in subsequent steps. Each extraction uses a JSONPath expression to locate the value in the response body. Extracted values become template variables accessible via {{variable_name}}.

{
"extract": {
"user_id": "$.data.id",
"user_name": "$.data.name"
}
}

After this step executes, {{user_id}} and {{user_name}} are available in all subsequent steps.

The following example creates a user, extracts the returned ID, and then retrieves the user to verify it was created correctly.

Step 1 — Create user:

{
"step_key": "create_user",
"action": "http_request",
"config": {
"method": "POST",
"url": "{{api_base_url}}/users",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer {{auth_token}}"
},
"body": "{\"name\": \"Test User\", \"email\": \"test@example.com\"}"
},
"assertions": [
{ "type": "status_code", "expected": 201 },
{ "type": "json_path", "expression": "$.data.name", "equals": "Test User" }
],
"extract": {
"created_user_id": "$.data.id"
}
}

Step 2 — Retrieve user:

{
"step_key": "get_user",
"action": "http_request",
"depends_on": ["create_user"],
"config": {
"method": "GET",
"url": "{{api_base_url}}/users/{{created_user_id}}",
"headers": {
"Authorization": "Bearer {{auth_token}}"
}
},
"assertions": [
{ "type": "status_code", "expected": 200 },
{ "type": "json_path", "expression": "$.data.email", "equals": "test@example.com" }
]
}

The condition field on a step controls whether the step executes based on a variable’s value. If the condition is not met, the step is skipped (same behavior as depends_on or requires skips).

Condition checks are evaluated after depends_on checks. The variables referenced in conditions typically come from extract in earlier steps.

FieldTypeRequiredDescription
typestringYesThe condition type: "variable_equals" or "variable_not_equals".
variablestringYesThe name of the variable to check.
valuestringYesThe value to compare against.

Execute the step only if the variable equals the specified value. If the variable is undefined, the condition is not met and the step is skipped.

{
"condition": {
"type": "variable_equals",
"variable": "user_role",
"value": "admin"
}
}

Execute the step only if the variable does NOT equal the specified value. If the variable is undefined, the condition is met and the step runs.

{
"condition": {
"type": "variable_not_equals",
"variable": "payment_method",
"value": "free"
}
}

The browser action drives a headless browser using Playwright.

FieldTypeRequiredDescription
stepsBrowserStep[]YesAn ordered array of browser actions to execute.
timeout_msnumberNoMaximum time in milliseconds for the entire browser action sequence.

Each entry in the steps array is an object with a type field and type-specific parameters.

TypeParameterDescription
gotostring (URL)Navigate to a URL. Supports template variables.
TypeParameterDescription
clickstring (CSS selector)Click an element.
double_clickstring (CSS selector)Double-click an element.
hoverstring (CSS selector)Hover over an element.
TypeParameterDescription
type{ selector: string, text: string }Type text into an input field. Both fields support template variables.
select_option{ selector: string, value: string }Select an option in a <select> element by value.
checkstring (CSS selector)Check a checkbox.
uncheckstring (CSS selector)Uncheck a checkbox.
press{ selector: string, key: string }Press a keyboard key while focused on an element (e.g., Enter, Tab).
focusstring (CSS selector)Focus an element.
upload_file{ selector: string, path: string }Upload a file to a file input element.
TypeParameterDescription
wait_for_selectorstring (CSS selector)Wait until an element matching the selector appears in the DOM.
wait_for_urlstring (URL substring)Wait until the page URL contains the given substring.
TypeParameterDescription
screenshotstring (name)Take a screenshot and save it with the given name.
TypeParameterDescription
set_headerRecord<string, string>Set custom HTTP headers for subsequent browser requests.
TypeParameterDescription
switch_to_framestring (CSS selector)Switch the execution context to an iframe identified by the selector.
switch_to_main_frametrueSwitch back to the main frame.

The browser context is shared across all steps within a single scenario. This means:

  • Login sessions persist across steps.
  • Cookies and localStorage are preserved.
  • Navigation history is maintained.

The browser’s storageState (cookies and localStorage) is carried over from one scenario to the next. This allows you to perform a login in an early scenario and have that session available in all subsequent scenarios without repeating the authentication flow.

The following example tests a login flow and verifies the user lands on the dashboard.

Step 1 — Login:

{
"step_key": "login",
"action": "browser",
"config": {
"steps": [
{ "type": "goto", "url": "{{web_base_url}}/login" },
{ "type": "type", "selector": "#email", "text": "{{test_email}}" },
{ "type": "type", "selector": "#password", "text": "{{test_password}}" },
{ "type": "click", "selector": "button[type='submit']" },
{ "type": "wait_for_url", "url": "/dashboard" }
],
"timeout_ms": 15000
},
"assertions": [
{ "type": "url_contains", "expected": "/dashboard" },
{ "type": "element_visible", "selector": "[data-testid='welcome-message']" }
]
}

Step 2 — Verify dashboard content:

{
"step_key": "verify_dashboard",
"action": "browser",
"depends_on": ["login"],
"config": {
"steps": [
{ "action": "wait_for_selector", "selector": "[data-testid='user-name']" }
]
},
"assertions": [
{ "type": "element_text", "selector": "[data-testid='user-name']", "expected": "Test User" },
{ "type": "element_visible", "selector": "[data-testid='recent-activity']" }
]
}