Create a response
POST /api/v1/responses starts a browser-agent run. Default routing: "smart" is production — the router uses the brain to pick and fall back. routing: "learning" and "learning-all" exist to teach the router the best path: they calibrate the brain on a domain/task so later smart runs pick the winning runner (paid calibration; not the production default). The response is OpenAI-compatible, with Banana Peel details under banana_peel — runner used (the runnerfield), ranking chain, timing, cost, session links, and artifacts. The same keys appear for every runner (null when a field doesn't apply). For OpenAI SDK baseURL / streaming notes see OpenAI Responses compatibility.
export BANANA_PEEL_BASE=https://bananapeel.com
export BANANA_PEEL_API_KEY=bp_live_…
curl -s "$BANANA_PEEL_BASE/api/v1/responses" \
-H "Authorization: Bearer $BANANA_PEEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "banana-peel",
"input": "Extract the title from https://example.com",
"routing": "smart"
}'SLA profile
On routing: "smart", sla_profile shifts how the router weighs cost, speed, and block-avoidance against success probability. It does not pin a runner and has no effect on named-runner, chain, category, or Learning routing.
balanced(default) — mix of success rate, cost, and speed.cheapest— prefer lower expected cost; a proven-but-pricey runner can lose the primary slot.fastest— prefer lower expected latency.most_reliable— prefer success probability and block-avoidance; a pricey winner can keep the primary slot.
Field table below is generated from OpenAPI. More on scoring: Smart routing.
Fallback count (max_fallbacks)
On routing: "smart", max_fallbacks is how many additional runners may run after the first pick (1 + N). The default is 5 — up to six attempts unless spend, time, or a repeating-error breaker stops earlier. Pass 0 to pin to the first choice only. Values must be integers from 0 to 12; a malformed value fails free with 400 invalid_max_fallbacks. Staggered race still starts the primary first; this knob is the re-route depth, not an all-at-once launch.
Spend ceiling (max_cost_usd)
The spend rules in one place: every executed attempt bills — including failed attempts and parallel or staggered fallbacks. On routing: "smart" the router launches up to max_fallbacks additional runners after the primary (default 5, so up to six billable attempts), and when the planned set exhausts with spend headroom left, budget-governed widening can dispatch further attempts one at a time — widened attempts bill like any other and respect the same max_cost_usd ceiling (a widened dispatch is only chosen while its expected cost fits the remaining headroom, and the widening steps are disclosed in the routing summary). Real cost can therefore climb well past a single attempt's price; max_cost_usd is the hard bound on all of it.
max_cost_usd caps what a single run may spend, in every routing mode. Once the attempts that already executed reach the ceiling, no new attempts, waves, batches, or widening dispatches are launched; attempts still in flight finish and bill. The run then ends honestly with error code budget_exhausted and partial results under banana_peel.routing_attempts. Learning runs (paid calibration to teach the best path) default to a $20 ceiling (learning-all defaults to $20) when max_cost_usdis omitted (an explicit value overrides the default in either direction); smart runs are additionally bounded by the routing plan's own pre-flight budget. The ceiling that governed dispatch is echoed under banana_peel.budget (max_cost_usd, source, exhausted), and POST /api/v1/estimate reflects it before you run. A malformed value fails free with 400 invalid_max_cost_usd.
Time budget (time_budget_ms) — uncapped by default
time_budget_ms is an optional run time ceiling for routing: "smart", "category", "learning" and "learning-all". When set, it governs the whole run: smart attempts share the window (a doomed primary cannot burn all of it), learning sweeps stop dispatching new batches once it elapses (in-flight attempts finish and bill, same contract as the spend ceiling), and the run is expired at budget + a 10-minute grace if the worker never persists a terminal state. Minimum 30000 (30 s); there is no upper bound. A malformed value fails free with 400 invalid_time_budget_ms; pinned runner/chain routing rejects the parameter with 400 time_budget_unsupported. timeout_ms is accepted as an alias.
When omitted, the run is uncapped: no run-level deadline exists and orchestration never cuts a working attempt to satisfy a global clock — attempts defer to each runner's own leash (e.g. Skyvern's ~6.5-minute poll budget) and the per-attempt watchdog, so the fallback chain still advances past a stuck runner. Uncapped does not mean unprotected: the no-progress watchdog, repeating-error detection, and runner probation stay fully active, and the lifecycle reaper still expires runs whose executor died (no progress heartbeat for the whole mode-aware window) plus an absolute 24-hour safety ceiling. Spend ceilings (max_cost_usd) are independent and unchanged.
Use background mode for uncapped runs.A synchronous or streaming request is still bounded by the platform's HTTP request timeout — an uncapped run should be created with background: true (or Prefer: respond-async) and polled / webhooked to terminal. The active contract is echoed on every response as banana_peel.time_budget_ms (the number when set; null plus uncapped: true when uncapped).
Background execution
Browser jobs often take several minutes. Pass background: true (or Prefer: respond-async) to get HTTP 202 with status: "in_progress" and an id. Polling GET /api/v1/responses/:id until terminal is fine for v0. At production scale, register outbound webhooks and let Banana Peel POST response.completed / .failed / .cancelled / response.requires_action (HMAC-signed) so you are not sitting on GET. Polling remains valid.
Idempotency
Send an Idempotency-Key HTTP header (Stripe / IETF style; header names are case-insensitive) or idempotency_key on the JSON body — they are equivalent. If both are present they must match; different values fail free with 400 idempotency_key_mismatch. Keys are up to 255 characters, scoped to your workspace, shared across every run-creating endpoint (/v1/responses, /v1/messages, and the provider-compatible wrapper creates), honored on every create mode (default sync, background: true / Prefer: respond-async, and stream: true), and expire 24 hours after first use.
- Replay (same key, same request). The key is reserved before dispatch and bound to the run it creates, together with a fingerprint of the request body (the key itself and the delivery-mode fields
stream/backgroundare excluded — a request keyed via header or via body fingerprints identically). Reusing the key with an identical body returns the original run — originalcreated_at, current state (200 when terminal, 202 while still running, failures included) — and never starts a duplicate. Replays carryIdempotent-Replayed: trueand echoIdempotency-Keyin the response headers. - Conflict (same key, different request). Reusing a key with a different body within its 24h window is refused with
422 idempotency_conflict— it almost always means a retry loop is mutating its payload. Use a fresh key for the new request, or resend the exact original body to replay. Nothing is created or billed. - Concurrent duplicates. Once the run exists, duplicates replay it (202 while in flight). A duplicate that lands during the brief admission window — after the key is reserved, before the run row is persisted — is refused with
409 idempotency_in_flightplusRetry-After: 1; resend the identical request to receive the original run. - Failed creates don't consume keys. A create refused before its run exists (validation, insufficient credits, custody, capacity 429s) releases the key, so an honest retry with the same key succeeds.
Scope notes: the Browserbase-compatible Sessions proxy (/api/v1/sessions) is a pass-through to the upstream Sessions API — it creates browser sessions, not runs, so run idempotency does not apply there. Keys attached to runs created before this feature shipped replay without fingerprint verification (there is nothing stored to verify against).
Create — field reference
POST/api/v1/responses (createResponse)
Create a browser-agent response
Parameters
| Field | Type | Req | Description |
|---|---|---|---|
| Idempotency-Key | header: string | — | Client retry key (Stripe / IETF idempotency-key style; header names are case-insensitive). Equivalent to the body `idempotency_key` — if both are sent they must match (400 `idempotency_key_mismatch` otherwise). Keys are scoped to the workspace, shared across every run-creating endpoint (/v1/responses, /v1/messages, wrapper creates), and expire 24 hours after first use. Reuse with an IDENTICAL body (delivery-mode fields `stream`/`background` excluded) replays the original run — original `created_at`, `Idempotent-Replayed: true` response header, 200 when terminal / 202 while running. Reuse with a DIFFERENT body is refused with 422 `idempotency_conflict`. A duplicate sent while the original create is still being admitted is refused with 409 `idempotency_in_flight` + `Retry-After: 1` — retry to receive the original run. A create refused before its run exists (validation, credits, custody, capacity) does not consume the key. |
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| model | string default "banana-peel" | — | — |
| input | string | array | object | — | Task text (string, OpenAI content array, or structured {url, goal, success_criteria, steps}). Required unless `url` alone is enough with goal/task. |
| url | string (uri) | — | — |
| instructions | string | — | — |
| goal | string | — | — |
| task | string | — | — |
| credential_id | string | — | Optional vault credential id for the API key owner (create/list via /v1/credentials). After custody is satisfiable, secrets are decrypted and injected into the task for whichever runner routing selects (smart, steel, browserbase, …). Vault usage never pins routing to deck (deck_only is an opt-in custody policy; create default is trusted_runners). When routing does select Deck — pinned, smart, race, or learning — the credential is additionally attached through Deck’s native vault (registered per source, reused across runs), since Deck gates login-walled sources on an attached credential rather than the instruction text. |
| stream | boolean | — | OpenAI Responses SSE stream (text/event-stream). Takes precedence over background for this request. |
| background | boolean | — | Return 202 immediately and finish in the background. Preferred for human MFA/OTP relay (poll + POST …/input). |
| metadata | object | — | OpenAI metadata (string values); echoed on the response object. |
| allow_interaction | boolean | — | Human-in-the-loop. Default on (response id is the relay channel). false opts out; true refuses non-relay runners. |
| interaction_timeout_ms | integer | — | Max wait per MFA/OTP/text question (default 240000). |
| totp_secret | string | — | Base32 TOTP secret — OTP asks answered in-process (prefer vault credential_id). |
| totp_identifier | string | — | Skyvern-native TOTP inbox/phone identifier. |
| totp_url | string | — | Skyvern-native TOTP poll URL. |
| normalize | boolean | — | Paid output normalization (+$0.10 / run). Requires output_schema (or text.format / response_format schema). Build schemas in Console → Normalize. |
| output_schema | object | — | Desired output JSON Schema (or property map). Used when normalize is true or when a schema is supplied via text.format / response_format. HARD CONTRACT: a terminal success must populate exactly these keys (required keys present, non-empty, right type; no undeclared extra data keys unless additionalProperties: true — run metadata like url/final_url is tolerated). A run whose output cannot satisfy the schema terminates status:failed with error.code schema_violation and the nonconforming output kept under banana_peel.output for inspection; the normalization add-on is not billed on violation. Without a schema, read the stable default envelope instead: banana_peel.answer / answer_present / evidence. |
| success_criteria | string | — | Explicit success criterion for the quality grader (e.g. "an order number is present"). |
| steps | string[] | — | Optional ordered steps for structured task input. |
| credentials_ref | string | — | Alias for credential_id (vault credential). |
| custody | string | — | Credential custody policy for THIS request: any | trusted_runners | deck_only | pinned:<runner>[,<runner>…]. Hard-filters the runner candidate set before any routing / scoring / fan-out; when no runner survives, the request fails fast with 409 `custody_unsatisfiable` and nothing is billed. Use with inline credentials in the task text; vault credentials carry their own stored policy (both compose as AND). Enforcement is echoed under `banana_peel.custody`. |
| text | object | — | OpenAI Responses text.format passthrough (json_schema → normalize). |
| response_format | object | — | OpenAI-style response_format (json_schema / json_object). |
| idempotency_key | string | — | Client retry key — equivalent to the `Idempotency-Key` HTTP header (send either; if both, they must match or the create fails with 400 `idempotency_key_mismatch`). Honored on sync, background (`background: true` / Prefer: respond-async), and streaming create; keys are per-workspace, shared across every run-creating endpoint, and expire 24h after first use. The key is reserved before dispatch: reuse with an identical body (`stream`/`background` excluded from the comparison) replays the existing run (original `created_at`, `Idempotent-Replayed: true` header) instead of starting a duplicate; reuse with a different body is refused with 422 `idempotency_conflict`; a duplicate racing the original create gets 409 `idempotency_in_flight`. |
| routing | string | string[] default "smart" | — | Optional (default smart). Learning and learning-all exist to teach the router the best path: they calibrate the routing brain on a domain/task so later smart runs pick the winning runner. smart = production (router uses the brain to pick/fallback) | learning = paid calibration: probe the curated live ~54-single-runner pool on this task/URL (not the default for production traffic) | learning-all = paid thorough calibration: full-catalog sweep (250+ options, NO early stop; most expensive; do not use for every production job) | named runner (browserbase, steel, browser-use, skyvern, hyperbrowser, playwright, deck, …) | category MVP pool (SOC2/HIPAA/EU — fixed runner lists, not attestation) | ordered runner list. Learning probes batches of 9 (concurrency 8) and stops after the first wave containing a success; learning-all keeps the same waves but sweeps the full catalog (250+ options): every dispatchable option attempts once; coming-soon catalog rows are skipped (never billed); $20 default ceiling (a catalog-wide sweep typically hits it). Learning-family runs are paid calibration sweeps (executed attempts bill); winners ranked fastest+cheapest feed future smart routing. |
| sla_profile | string balanced | cheapest | fastest | most_reliable default "balanced" | — | Smart-routing preference: how the router weighs cost, speed, and block-avoidance against success probability when choosing runners. Only affects routing: "smart". |
| max_fallbacks | integer default 5 | — | Smart routing: how many additional runners after the primary (1 + N). Default 5 (up to 6 attempts unless spend, time, or a breaker stops earlier). 0 pins to the first pick only. Invalid values fail free with 400 invalid_max_fallbacks. Only affects routing: "smart" (and category). Staggered race still starts the primary first. |
| max_cost_usd | number | — | Per-run spend ceiling (USD), honored by every routing mode. Once executed attempts’ spend reaches the ceiling, no NEW attempts / batches / waves are dispatched; in-flight attempts finish (and bill) and the run ends with error code `budget_exhausted` plus partial results under `banana_peel.routing_attempts`. Learning runs default to $20 when omitted (an explicit max_cost_usd overrides the default in either direction). The active ceiling is echoed under `banana_peel.budget` and reflected by POST /v1/estimate. |
| time_budget_ms | integer | — | OPTIONAL run time budget (ms) for routing: "smart", "category", "learning", and "learning-all". When set it is THE orchestration ceiling: smart attempts share the window, learning sweeps stop dispatching new batches at it, and the run is reaped at budget + a 10-minute grace. When OMITTED the run is UNCAPPED — no run-level deadline exists; attempts defer to adapter-native leashes and per-attempt watchdogs, and only true-hang protection (no-progress watchdog, repeating-error detection, progress-staleness reaping, and a 24 h absolute safety ceiling) can stop the run. Uncapped runs should use background: true — a synchronous request is still bounded by the HTTP request timeout. Not accepted for pinned runner/chain routing (400 time_budget_unsupported). Values below 30000 fail free with 400 invalid_time_budget_ms; there is no upper bound. Spend ceilings (max_cost_usd) are independent. Alias: timeout_ms. The active contract is echoed as banana_peel.time_budget_ms (null + uncapped: true when uncapped). |
Response
| Field | Type | Req | Description |
|---|---|---|---|
| id | string | — | — |
| object | "response" | — | — |
| created_at | integer | — | Unix seconds |
| status | string completed | failed | in_progress | requires_action | cancelled | — | — |
| required_action | object | — | Present when status is requires_action — owner secrets only (MFA/OTP/text/confirm). Never emitted for captcha / bot challenges. Visuals (live_view / screenshot / replay_url) are included when available. |
| required_action.type | "submit_input" | — | — |
| required_action.submit_input | object | — | — |
| required_action.submit_input.kind | string otp | text | confirm | — | — |
| required_action.submit_input.prompt | string | — | — |
| required_action.submit_input.created_at | integer | — | — |
| required_action.submit_input.channel | string totp | sms | email | unknown | — | Best-effort delivery channel inferred from the prompt. |
| required_action.submit_input.message | string | — | Human-readable ask (same as prompt). |
| required_action.submit_input.submit | object | — | How to send the code. |
| required_action.submit_input.live_view | string | — | — |
| required_action.submit_input.screenshot | string | — | — |
| required_action.submit_input.replay_url | string | — | — |
| incomplete_details | object | — | — |
| model | string | — | — |
| confidence | number | — | — |
| output | object[] | — | — |
| output_text | string | — | ALWAYS a plain string, on every runner: freeform answers verbatim; structured results (including output_schema-normalized ones) as pretty-printed JSON. The structured value itself is under banana_peel.output. Guaranteed never a character-indexed map ({"0":"T","1":"h",…}) and never double-encoded JSON-in-a-string — outputs are repaired/unwrapped at the canonical normalization choke point before the envelope is built or persisted. |
| usage | object | — | Approximate tokens (char/4); billing is USD under banana_peel.billing |
| usage.input_tokens | integer | — | — |
| usage.output_tokens | integer | — | — |
| usage.total_tokens | integer | — | — |
| metadata | object | — | — |
| error | object | — | — |
| error.code | string | — | — |
| error.message | string | — | — |
| error.remediation | string | — | — |
| banana_peel | object | — | Canonical banana_peel.run/v1 — superset over all runners. `runner` is the executed runner slug; `routing` is what you requested; `engine` is the execution backend when distinct. |
| banana_peel.schema | "banana_peel.run/v1" | — | — |
| banana_peel.id | string | — | — |
| banana_peel.status | string succeeded | failed | running | blocked | empty | needs_human | cancelled | — | — |
| banana_peel.progress | object | — | Live in-flight visibility (only while status is in_progress / requires_action): runner attempts so far, current Learning batch, and runner spend to date. Superseded by routing_attempts on the terminal object. |
| banana_peel.progress.attempts | object[] | — | — |
| banana_peel.progress.attempts_started | integer | — | — |
| banana_peel.progress.attempts_finished | integer | — | — |
| banana_peel.progress.batch | object | — | Present for Learning-mode batch probes (1-based current batch). |
| banana_peel.progress.spend_usd | number | — | Runner spend so far (pre-fee). |
| banana_peel.progress.updated_at | string (date-time) | — | — |
| banana_peel.required_action | object | — | MFA/OTP pause mirror (prompt/kind only; never the secret). |
| banana_peel.model | string | — | — |
| banana_peel.routing | any | — | — |
| banana_peel.routing_note | string | — | — |
| banana_peel.ranked_runners | string[] | — | — |
| banana_peel.parent_id | string | — | Present on long-task sub-runs only — the parent long task (lt_…). See GET /v1/long-tasks/{id}/runs. |
| banana_peel.entity_id | string | — | Present on long-task sub-runs — the manifest entity this run serves (__enumeration__ for the enumeration step). |
| banana_peel.role | string enumeration | entity_fetch | verification | — | Role of a long-task sub-run within its parent. verification = a sampled spot-check confirming randomly chosen claimed entity ids exist. |
| banana_peel.routing_summary | object | — | Compact "why was it routed there" summary — present on every brain-routed run (routing: "smart"/category and forced-runner decisions the brain recorded; absent on pinned/chain/learning runs, which have no decision to explain). In-flight polls carry the honest base (planned primary + decision reason); the terminal object adds per-attempt fault attribution, the policy version, and a budget-widening echo. The `summary` sentence is redacted before it leaves the API. The same object is embedded in response.completed / response.failed webhook payloads (data.banana_peel.routing_summary). For the full decision card (candidate EU table, site defense, widening steps + stop reasons) follow `explanation` — GET /v1/responses/{id}/explanation. |
| banana_peel.routing_summary.runner | string | — | Executed runner (terminal) or the planned primary (in-flight). |
| banana_peel.routing_summary.strategy | string | — | explore | exploit | forced | fallback (additive enum; widened runs report explore). |
| banana_peel.routing_summary.reason | string | — | Machine reason code: exploit_expected_utility, exploit_recent_winner, explore_cold_domain, explore_low_confidence, explore_floor, explore_streak_breaker, explore_budget_widening, forced_by_caller, fallback_static_chain, … (additive enum). |
| banana_peel.routing_summary.summary | string | — | One redacted plain-words sentence — the same headline the console decision card shows. |
| banana_peel.routing_summary.policy_version | string | — | Routing policy version that made the decision (terminal runs). |
| banana_peel.routing_summary.attempts | object[] | — | Executed attempts in dispatch order. |
| banana_peel.routing_summary.widening | object | — | Present when the run widened past the planned candidate set (budget-governed widening). |
| banana_peel.routing_summary.explanation | string | — | Path to the full decision card: /api/v1/responses/{id}/explanation. |
| banana_peel.runner | string | — | Executed runner slug |
| banana_peel.engine | string | — | Execution backend behind the runner when distinct (e.g. browser-use-cloud) |
| banana_peel.url | string | — | — |
| banana_peel.output | any | — | Run result in canonical shape, identical across runners: a plain string for freeform tasks, or a structured object/array when the runner returned real JSON (or output_schema was requested). Normalized at a single choke point before persist + envelope build: char-exploded strings ({"0":"T","1":"h",…}) are reassembled and JSON-encoded strings unwrapped, so the shape never varies by runner. With an output_schema this is a HARD CONTRACT: a terminal success populates exactly the schema keys, or the run is failed with error.code schema_violation. |
| banana_peel.answer | string | — | GUARANTEED READ PATH (stable default envelope): best-effort distilled answer string, always present as a key. Runner outputs name their keys differently run to run (current_outstanding_balance vs result vs prose-in-summary) — `answer` is the stable projection, so clients without an output_schema never chase keys. Null exactly when answer_present is false. For legitimately empty results the answer states the empty result (e.g. the "No Order History Found" evidence); for download tasks it lists the delivered files. |
| banana_peel.answer_present | boolean | — | Delivery-verdict bit: was the REQUESTED ANSWER actually delivered? True on every genuine success — including a legitimate empty/zero result ("0 orders" IS the answer; empty-result ≠ failure). False on failed/blocked runs and on hollow completions (output that only narrates login/click actions without the requested value — those are status failed, never billed-and-claimed-successful). Meaningful on terminal states; false while still running. |
| banana_peel.evidence | object | — | Evidence trail backing `answer`: verdict reason, final URL, artifact names. |
| banana_peel.evidence.quality_reason | string | — | — |
| banana_peel.evidence.final_url | string | — | — |
| banana_peel.evidence.artifacts | string[] | — | — |
| banana_peel.error | object | — | — |
| banana_peel.quality | object | — | Delivery verdict from the honesty layer (deterministic checks + QA grader): did the run deliver the requested answer? Terminal `status` follows this verdict — not the runner’s mechanical exit code — and the routing scoreboard/brain learn from the same verdict-based labels. |
| banana_peel.quality.verdict | string succeeded | blocked | failed | empty | — | — |
| banana_peel.quality.confidence | number | — | — |
| banana_peel.quality.reason | string | — | — |
| banana_peel.quality.source | string deterministic | grader | — | — |
| banana_peel.quality.answer_present | boolean | — | Mirrored to banana_peel.answer_present (see there). |
| banana_peel.deliverables | object | — | Delivery verdict for file-deliverable tasks: claimed downloads vs files actually captured as retrievable artifacts. Null for text-only tasks. A run that claims downloads but delivers zero captured files is demoted to failed (verdict not_delivered) — in-session download URLs expire with the browser session and are not deliverables. In structured output, `download_succeeded` is renamed `download_triggered` (the in-session click) with a sibling `file_delivered` boolean (actual capture). |
| banana_peel.deliverables.claimed | integer | — | Files the run output claims were downloaded. |
| banana_peel.deliverables.delivered | integer | — | File artifacts actually captured and retrievable. |
| banana_peel.deliverables.verdict | string delivered | partial | not_delivered | — | — |
| banana_peel.deliverables.note | string | — | — |
| banana_peel.block_reason | string | — | — |
| banana_peel.timing_ms | number | — | — |
| banana_peel.cost | object | — | — |
| banana_peel.normalization | object | — | Output normalization echo — present whenever normalization was requested. `applied: true` means the reshape pass executed (and the +$0.10 add-on was billed); `applied: false` includes a short `reason` and `addon_usd: 0` (never charged for a pass that did not run, and never charged when the output_schema contract was violated — see error.code schema_violation). |
| banana_peel.normalization.requested | true | — | — |
| banana_peel.normalization.applied | boolean | — | — |
| banana_peel.normalization.reason | string | — | Why the pass did not apply; null when applied. |
| banana_peel.normalization.addon_usd | number | — | Add-on billed for this run (0.1 when applied, else 0). |
| banana_peel.custody | object | — | Custody enforcement echo — present whenever a credential custody policy applied to this run (vault credential and/or request `custody`). The runner candidate set was hard-filtered to `allowed_runners` BEFORE routing; no fallback, race, wave, or learning probe left that set. Persisted on the run record for audit. |
| banana_peel.custody.policy | string | — | Composed policy label (any | trusted_runners | deck_only | pinned:<runner> — joined with + when composed). |
| banana_peel.custody.source | string vault | request | vault+request | — | — |
| banana_peel.custody.credential_id | string | — | Vault credential id; null for inline credentials. |
| banana_peel.custody.allowed_runners | string[] | — | Effective allowed-runner set after the filter (capped at 40 slugs). |
| banana_peel.custody.allowed_runner_count | integer | — | — |
| banana_peel.custody.candidates_before_filter | integer | — | — |
| banana_peel.custody.enforced | true | — | — |
| banana_peel.session | object | — | Metadata about the browser this run used (live view, replay, CDP connect). A record of the run’s browser — not a standalone session you manage. Unused fields are null. |
| banana_peel.session.id | string | — | — |
| banana_peel.session.provider_id | string | — | — |
| banana_peel.session.live_url | string | — | — |
| banana_peel.session.replay_url | string | — | — |
| banana_peel.session.connect_url | string | — | — |
| banana_peel.session.selenium_remote_url | string | — | — |
| banana_peel.session.region | string | — | — |
| banana_peel.session.status | string | — | — |
| banana_peel.live_view | string | — | — |
| banana_peel.replay_url | string | — | — |
| banana_peel.screenshot | string | — | — |
| banana_peel.screenshot_unavailable_reason | string | — | — |
| banana_peel.artifacts | any[] | — | — |
| banana_peel.artifacts[] | object | — | A file captured from the run (downloads, screenshots, recordings). Captured artifacts are hosted by Banana Peel: `url` points at the authenticated retrieval endpoint (GET /v1/responses/{id}/artifacts/{artifactId}, same API-key auth), which 302-redirects to a short-lived signed URL (~10 min TTL) or streams the bytes. Artifacts follow the run retention window (default 7 days) and are purged on response delete / account deletion. Per-file capture cap is 30 MB; up to 10 artifacts per run. When bytes could not be captured, `url` falls back to the provider-hosted URL (typically session-gated and short-lived) and `unavailable_reason` says why. |
| banana_peel.steps | object[] | — | — |
| banana_peel.fallback_from | string[] | — | — |
| banana_peel.act_script | array | — | — |
| banana_peel.created_at | string (date-time) | — | — |
| banana_peel.finished_at | string (date-time) | — | — |
HTTP statuses
200— Completed / failed / blocked JSON, or text/event-stream when stream:true202— Queued / still running (background)400— Invalid request401— Invalid API key402— Insufficient credits409— Conflict — nothing was created and nothing was billed. Error code `custody_unsatisfiable`: the credential custody policy and the requested routing have no runner in common (loosen the policy in Console → Vault or PATCH /v1/credentials/{id}, or route within the allowed set). Error code `idempotency_in_flight`: a request with the same Idempotency-Key is still being admitted — honor `Retry-After` (~1s) and resend the identical request to receive the original run.422— Idempotency conflict (error code `idempotency_conflict`): the Idempotency-Key was already used with DIFFERENT request parameters within its 24h window — almost always a client bug (a retry loop mutating its payload). Use a fresh key for the new request, or resend the exact original body to replay the original run. Nothing was created and nothing was billed.429— Backpressure — no run was created and nothing was billed; honor the `Retry-After` header (seconds) and retry. Error code `rate_limited`: this account already has the maximum concurrent Learning-family runs in flight (`"learning"` + `"learning-all"`, default 3). Error code `learning_capacity`: Learning fan-out execution capacity is saturated on this instance.502— Run failed at transport layer
Generated from OpenAPI. Do not hand-maintain this table.
Retrieve
GET/api/v1/responses/{id} (getResponse)
Get a response by id
Parameters
| Field | Type | Req | Description |
|---|---|---|---|
| id | path: string | yes | — |
Response
| Field | Type | Req | Description |
|---|---|---|---|
| id | string | — | — |
| object | "response" | — | — |
| created_at | integer | — | Unix seconds |
| status | string completed | failed | in_progress | requires_action | cancelled | — | — |
| required_action | object | — | Present when status is requires_action — owner secrets only (MFA/OTP/text/confirm). Never emitted for captcha / bot challenges. Visuals (live_view / screenshot / replay_url) are included when available. |
| required_action.type | "submit_input" | — | — |
| required_action.submit_input | object | — | — |
| required_action.submit_input.kind | string otp | text | confirm | — | — |
| required_action.submit_input.prompt | string | — | — |
| required_action.submit_input.created_at | integer | — | — |
| required_action.submit_input.channel | string totp | sms | email | unknown | — | Best-effort delivery channel inferred from the prompt. |
| required_action.submit_input.message | string | — | Human-readable ask (same as prompt). |
| required_action.submit_input.submit | object | — | How to send the code. |
| required_action.submit_input.live_view | string | — | — |
| required_action.submit_input.screenshot | string | — | — |
| required_action.submit_input.replay_url | string | — | — |
| incomplete_details | object | — | — |
| model | string | — | — |
| confidence | number | — | — |
| output | object[] | — | — |
| output_text | string | — | ALWAYS a plain string, on every runner: freeform answers verbatim; structured results (including output_schema-normalized ones) as pretty-printed JSON. The structured value itself is under banana_peel.output. Guaranteed never a character-indexed map ({"0":"T","1":"h",…}) and never double-encoded JSON-in-a-string — outputs are repaired/unwrapped at the canonical normalization choke point before the envelope is built or persisted. |
| usage | object | — | Approximate tokens (char/4); billing is USD under banana_peel.billing |
| usage.input_tokens | integer | — | — |
| usage.output_tokens | integer | — | — |
| usage.total_tokens | integer | — | — |
| metadata | object | — | — |
| error | object | — | — |
| error.code | string | — | — |
| error.message | string | — | — |
| error.remediation | string | — | — |
| banana_peel | object | — | Canonical banana_peel.run/v1 — superset over all runners. `runner` is the executed runner slug; `routing` is what you requested; `engine` is the execution backend when distinct. |
| banana_peel.schema | "banana_peel.run/v1" | — | — |
| banana_peel.id | string | — | — |
| banana_peel.status | string succeeded | failed | running | blocked | empty | needs_human | cancelled | — | — |
| banana_peel.progress | object | — | Live in-flight visibility (only while status is in_progress / requires_action): runner attempts so far, current Learning batch, and runner spend to date. Superseded by routing_attempts on the terminal object. |
| banana_peel.progress.attempts | object[] | — | — |
| banana_peel.progress.attempts_started | integer | — | — |
| banana_peel.progress.attempts_finished | integer | — | — |
| banana_peel.progress.batch | object | — | Present for Learning-mode batch probes (1-based current batch). |
| banana_peel.progress.spend_usd | number | — | Runner spend so far (pre-fee). |
| banana_peel.progress.updated_at | string (date-time) | — | — |
| banana_peel.required_action | object | — | MFA/OTP pause mirror (prompt/kind only; never the secret). |
| banana_peel.model | string | — | — |
| banana_peel.routing | any | — | — |
| banana_peel.routing_note | string | — | — |
| banana_peel.ranked_runners | string[] | — | — |
| banana_peel.parent_id | string | — | Present on long-task sub-runs only — the parent long task (lt_…). See GET /v1/long-tasks/{id}/runs. |
| banana_peel.entity_id | string | — | Present on long-task sub-runs — the manifest entity this run serves (__enumeration__ for the enumeration step). |
| banana_peel.role | string enumeration | entity_fetch | verification | — | Role of a long-task sub-run within its parent. verification = a sampled spot-check confirming randomly chosen claimed entity ids exist. |
| banana_peel.routing_summary | object | — | Compact "why was it routed there" summary — present on every brain-routed run (routing: "smart"/category and forced-runner decisions the brain recorded; absent on pinned/chain/learning runs, which have no decision to explain). In-flight polls carry the honest base (planned primary + decision reason); the terminal object adds per-attempt fault attribution, the policy version, and a budget-widening echo. The `summary` sentence is redacted before it leaves the API. The same object is embedded in response.completed / response.failed webhook payloads (data.banana_peel.routing_summary). For the full decision card (candidate EU table, site defense, widening steps + stop reasons) follow `explanation` — GET /v1/responses/{id}/explanation. |
| banana_peel.routing_summary.runner | string | — | Executed runner (terminal) or the planned primary (in-flight). |
| banana_peel.routing_summary.strategy | string | — | explore | exploit | forced | fallback (additive enum; widened runs report explore). |
| banana_peel.routing_summary.reason | string | — | Machine reason code: exploit_expected_utility, exploit_recent_winner, explore_cold_domain, explore_low_confidence, explore_floor, explore_streak_breaker, explore_budget_widening, forced_by_caller, fallback_static_chain, … (additive enum). |
| banana_peel.routing_summary.summary | string | — | One redacted plain-words sentence — the same headline the console decision card shows. |
| banana_peel.routing_summary.policy_version | string | — | Routing policy version that made the decision (terminal runs). |
| banana_peel.routing_summary.attempts | object[] | — | Executed attempts in dispatch order. |
| banana_peel.routing_summary.widening | object | — | Present when the run widened past the planned candidate set (budget-governed widening). |
| banana_peel.routing_summary.explanation | string | — | Path to the full decision card: /api/v1/responses/{id}/explanation. |
| banana_peel.runner | string | — | Executed runner slug |
| banana_peel.engine | string | — | Execution backend behind the runner when distinct (e.g. browser-use-cloud) |
| banana_peel.url | string | — | — |
| banana_peel.output | any | — | Run result in canonical shape, identical across runners: a plain string for freeform tasks, or a structured object/array when the runner returned real JSON (or output_schema was requested). Normalized at a single choke point before persist + envelope build: char-exploded strings ({"0":"T","1":"h",…}) are reassembled and JSON-encoded strings unwrapped, so the shape never varies by runner. With an output_schema this is a HARD CONTRACT: a terminal success populates exactly the schema keys, or the run is failed with error.code schema_violation. |
| banana_peel.answer | string | — | GUARANTEED READ PATH (stable default envelope): best-effort distilled answer string, always present as a key. Runner outputs name their keys differently run to run (current_outstanding_balance vs result vs prose-in-summary) — `answer` is the stable projection, so clients without an output_schema never chase keys. Null exactly when answer_present is false. For legitimately empty results the answer states the empty result (e.g. the "No Order History Found" evidence); for download tasks it lists the delivered files. |
| banana_peel.answer_present | boolean | — | Delivery-verdict bit: was the REQUESTED ANSWER actually delivered? True on every genuine success — including a legitimate empty/zero result ("0 orders" IS the answer; empty-result ≠ failure). False on failed/blocked runs and on hollow completions (output that only narrates login/click actions without the requested value — those are status failed, never billed-and-claimed-successful). Meaningful on terminal states; false while still running. |
| banana_peel.evidence | object | — | Evidence trail backing `answer`: verdict reason, final URL, artifact names. |
| banana_peel.evidence.quality_reason | string | — | — |
| banana_peel.evidence.final_url | string | — | — |
| banana_peel.evidence.artifacts | string[] | — | — |
| banana_peel.error | object | — | — |
| banana_peel.quality | object | — | Delivery verdict from the honesty layer (deterministic checks + QA grader): did the run deliver the requested answer? Terminal `status` follows this verdict — not the runner’s mechanical exit code — and the routing scoreboard/brain learn from the same verdict-based labels. |
| banana_peel.quality.verdict | string succeeded | blocked | failed | empty | — | — |
| banana_peel.quality.confidence | number | — | — |
| banana_peel.quality.reason | string | — | — |
| banana_peel.quality.source | string deterministic | grader | — | — |
| banana_peel.quality.answer_present | boolean | — | Mirrored to banana_peel.answer_present (see there). |
| banana_peel.deliverables | object | — | Delivery verdict for file-deliverable tasks: claimed downloads vs files actually captured as retrievable artifacts. Null for text-only tasks. A run that claims downloads but delivers zero captured files is demoted to failed (verdict not_delivered) — in-session download URLs expire with the browser session and are not deliverables. In structured output, `download_succeeded` is renamed `download_triggered` (the in-session click) with a sibling `file_delivered` boolean (actual capture). |
| banana_peel.deliverables.claimed | integer | — | Files the run output claims were downloaded. |
| banana_peel.deliverables.delivered | integer | — | File artifacts actually captured and retrievable. |
| banana_peel.deliverables.verdict | string delivered | partial | not_delivered | — | — |
| banana_peel.deliverables.note | string | — | — |
| banana_peel.block_reason | string | — | — |
| banana_peel.timing_ms | number | — | — |
| banana_peel.cost | object | — | — |
| banana_peel.normalization | object | — | Output normalization echo — present whenever normalization was requested. `applied: true` means the reshape pass executed (and the +$0.10 add-on was billed); `applied: false` includes a short `reason` and `addon_usd: 0` (never charged for a pass that did not run, and never charged when the output_schema contract was violated — see error.code schema_violation). |
| banana_peel.normalization.requested | true | — | — |
| banana_peel.normalization.applied | boolean | — | — |
| banana_peel.normalization.reason | string | — | Why the pass did not apply; null when applied. |
| banana_peel.normalization.addon_usd | number | — | Add-on billed for this run (0.1 when applied, else 0). |
| banana_peel.custody | object | — | Custody enforcement echo — present whenever a credential custody policy applied to this run (vault credential and/or request `custody`). The runner candidate set was hard-filtered to `allowed_runners` BEFORE routing; no fallback, race, wave, or learning probe left that set. Persisted on the run record for audit. |
| banana_peel.custody.policy | string | — | Composed policy label (any | trusted_runners | deck_only | pinned:<runner> — joined with + when composed). |
| banana_peel.custody.source | string vault | request | vault+request | — | — |
| banana_peel.custody.credential_id | string | — | Vault credential id; null for inline credentials. |
| banana_peel.custody.allowed_runners | string[] | — | Effective allowed-runner set after the filter (capped at 40 slugs). |
| banana_peel.custody.allowed_runner_count | integer | — | — |
| banana_peel.custody.candidates_before_filter | integer | — | — |
| banana_peel.custody.enforced | true | — | — |
| banana_peel.session | object | — | Metadata about the browser this run used (live view, replay, CDP connect). A record of the run’s browser — not a standalone session you manage. Unused fields are null. |
| banana_peel.session.id | string | — | — |
| banana_peel.session.provider_id | string | — | — |
| banana_peel.session.live_url | string | — | — |
| banana_peel.session.replay_url | string | — | — |
| banana_peel.session.connect_url | string | — | — |
| banana_peel.session.selenium_remote_url | string | — | — |
| banana_peel.session.region | string | — | — |
| banana_peel.session.status | string | — | — |
| banana_peel.live_view | string | — | — |
| banana_peel.replay_url | string | — | — |
| banana_peel.screenshot | string | — | — |
| banana_peel.screenshot_unavailable_reason | string | — | — |
| banana_peel.artifacts | any[] | — | — |
| banana_peel.artifacts[] | object | — | A file captured from the run (downloads, screenshots, recordings). Captured artifacts are hosted by Banana Peel: `url` points at the authenticated retrieval endpoint (GET /v1/responses/{id}/artifacts/{artifactId}, same API-key auth), which 302-redirects to a short-lived signed URL (~10 min TTL) or streams the bytes. Artifacts follow the run retention window (default 7 days) and are purged on response delete / account deletion. Per-file capture cap is 30 MB; up to 10 artifacts per run. When bytes could not be captured, `url` falls back to the provider-hosted URL (typically session-gated and short-lived) and `unavailable_reason` says why. |
| banana_peel.steps | object[] | — | — |
| banana_peel.fallback_from | string[] | — | — |
| banana_peel.act_script | array | — | — |
| banana_peel.created_at | string (date-time) | — | — |
| banana_peel.finished_at | string (date-time) | — | — |
HTTP statuses
200— Response401— Invalid API key404— Not found
Generated from OpenAPI. Do not hand-maintain this table.
Delete
DELETE/api/v1/responses/{id} (deleteResponse)
Delete a response and purge copied artifacts
Parameters
| Field | Type | Req | Description |
|---|---|---|---|
| id | path: string | yes | — |
Response
| Field | Type | Req | Description |
|---|---|---|---|
| id | string | — | — |
| object | "response" | — | — |
| deleted | true | — | — |
HTTP statuses
200— Deleted401— Invalid API key404— Not found (unknown, already deleted, or another workspace)
Generated from OpenAPI. Do not hand-maintain this table.
Canonical banana_peel fields
Every response includes a banana_peelenvelope (nulls OK when a field doesn't apply). The generated tables above are the field contract; this list is the conceptual map.
banana_peel.answer/answer_present/evidence— the guaranteed read path. Runners name output keys differently run to run;answeris the stable distilled answer string (null only when no answer was delivered),answer_presentsays whether the requested answer was actually delivered, andevidencecarries the verdict reason, final URL, and artifact names. A legitimate empty result ("0 orders", "No Order History Found") IS a delivered answer:status: succeededwithanswer_present: true. A hollow completion (output that only narrates login/click steps without the requested value) isstatus: failedwithanswer_present: false— terminal status follows the delivery verdict, never the runner's mechanical exit code, and routing learns from the same verdict-based labelsbanana_peel.session— id / live_url / replay_url / connect_url (nulls OK). Metadata about the browser the run used — not a standalone session you managebanana_peel.artifacts,steps,cost,fallback_frombanana_peel.ranked_runners— runners considered for smart/categoryconfidence/banana_peel.confidence— quality score when graded (null otherwise)banana_peel.ran_in— compliance pool whenroutingwasSOC2/HIPAA/EUbanana_peel.routing_summary— compact "why was it routed there" echo on brain-routed runs (see below)
Routing summary (why this runner)
Every brain-routed response carries a compact banana_peel.routing_summary: the executed runner, the explore/exploit strategy, the machine reason code, one redacted plain-words sentence (the same headline the console decision card shows), the routing policy version, per-attempt fault attribution, a budget-widening echo when the run widened past the planned set, and the path to the full decision card. In-flight polls carry the honest base (planned primary + decision reason); the terminal object adds attribution, policy version, and widening. Pinned single-runner routing has no decision to explain, so the field is absent — same rule as the explanation endpoint.
"banana_peel": {
"routing_summary": {
"runner": "stagehand",
"strategy": "exploit",
"reason": "exploit_expected_utility",
"summary": "Router picked stagehand on expected utility for this request…",
"policy_version": "bandit-v1",
"attempts": [
{ "runner": "browserbase", "status": "failed", "attribution": "runner" },
{ "runner": "stagehand", "status": "succeeded", "attribution": "runner" }
],
"widening": { "dispatches": 1, "stop_reason": null },
"explanation": "/api/v1/responses/resp_…/explanation"
}
}The summary sentence and every text-bearing field pass through the platform redaction pass before they leave the API. For the full card — candidate table with expected-utility decomposition, site-defense profile, widening steps and stop reasons, per-attempt rewards — fetch GET /api/v1/responses/{id}/explanation or read Routing explanations. The same object is embedded in response.completed / response.failed webhook payloads.
Output normalization (+$0.10)
Pass normalize: true with an output_schema (JSON Schema or property map) to reshape the runner result after the run. Also accepts OpenAI text.format / response_format schemas. Build the schema visually in Console → Normalize. Adds a flat $0.10 on top of runner cost + platform fee for that run (every executed run is billed — not success-only). Preview cost without running via POST /api/v1/estimate (same body; returns likely_runner + catalog total).
curl -s "$BANANA_PEEL_BASE/api/v1/responses" \
-H "Authorization: Bearer $BANANA_PEEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "banana-peel",
"input": {
"url": "https://example.com/orders",
"goal": "Extract the latest order",
"success_criteria": "an order_id is present"
},
"routing": "smart",
"normalize": true,
"output_schema": {
"type": "object",
"properties": {
"order_id": { "type": "string" },
"total": { "type": "string" }
},
"required": ["order_id", "total"]
}
}'Whenever normalization was requested, the response confirms what actually happened under banana_peel.normalization (also on GET /api/v1/responses/{id} when polling):
"banana_peel": {
"normalization": {
"requested": true,
"applied": true,
"reason": null,
"addon_usd": 0.1
}
}applied: true— the Output normalization pass ran and the +$0.10 add-on was billed (banana_peel.billing.normalization_usdmatches).applied: false— the pass did not run;reasonsays why (e.g. the run failed before normalization, or the pass errored) andaddon_usdis0— you are not charged for a pass that never executed.- The field is absent when normalization was not requested.
output_schema is a hard contract
When you supply an output_schema (directly, or via text.format / response_format), the terminal output must populate exactly those keys: every required key present and non-empty with the declared type, and no undeclared extra data keys unless your schema sets additionalProperties: true (run metadata like url / final_url is tolerated). A run whose output cannot satisfy the contract terminates status: "failed" with error.code: "schema_violation" and the violations listed in error.message — Banana Peel never bills-and-claims-success on drifted keys. The nonconforming output stays under banana_peel.output for inspection; runner attempts that executed still bill (same as any failed run) but the +$0.10 normalization add-on is dropped. Without a schema, read the stable default envelope instead: banana_peel.answer / answer_present / evidence.
Multi-account portals
Account-scoped value asks ("amount due", "balance", bills, usage) against portals that can hold several accounts (telco/utility dashboards, e.g. Videotron or Hydro-Québec) are automatically instructed to enumerate all accounts/profiles/contracts first and answer with a structured per-account array (accounts: [{"account_label", …}]) instead of a single scalar from whichever account the agent landed on. If you pinned an output_schema, your schema owns the shape and this guidance is not injected — declare an array field yourself when you expect several accounts.
Multi-entity jobs — orchestration disabled
Multi-entity long-task orchestration is disabled: POST /api/v1/long-tasks returns 403 feature_disabled, and every task — including "download the latest bill for ALL properties"-style jobs — runs as a single run right here. For a job that spans several entities, loop client-side and create one response run per entity (an idempotency_key per entity keeps retries from double-running). A task whose text looks multi-entity is never rejected on this endpoint; it runs single-shot and its envelope carries the informational banana_peel.scale_hint: "multi" flag. Previously created lt_… objects stay readable — see Long tasks (disabled).
Live progress while in flight
For background: true runs, each poll of GET /api/v1/responses/{id} exposes live progress under banana_peel.progress while the status is in_progress / requires_action: runner attempts so far, the current Learning batch, and runner spend to date. On the terminal object it is superseded by banana_peel.routing_attempts.
"banana_peel": {
"progress": {
"attempts": [
{ "runner": "browserbase", "status": "failed", "started_at": "…", "finished_at": "…", "cost_usd": 0.04 },
{ "runner": "steel", "status": "running", "started_at": "…" }
],
"attempts_started": 2,
"attempts_finished": 1,
"batch": { "current": 1, "total": 4, "size": 5 },
"spend_usd": 0.04,
"updated_at": "…"
}
}Cancel a run
POST /api/v1/responses/{id}/cancel stops an in-flight run (OpenAI-compatible — returns the response object with status: "cancelled"). Especially useful for Learning-mode batch probes, which bill every attempt.
curl -s -X POST "$BANANA_PEEL_BASE/api/v1/responses/$ID/cancel" \
-H "Authorization: Bearer $BANANA_PEEL_API_KEY"
# → { "id": "resp_…", "status": "cancelled", … }- Runner attempts that already executed still bill (every-attempt billing); no new attempts or Learning batches start after the cancel.
- In-flight upstream attempts are aborted best-effort where the adapter supports it (e.g. Skyvern); otherwise they finish server-side without further billing.
- Cancelling an already-finished run is a no-op — the object is returned unchanged.
- Independently of cancel, no run sits at
in_progressforever: runs with atime_budget_msare expired at budget + a 10-minute grace; uncapped smart/learning-family runs (the default — see Time budget) are expired only when their executor provably died (no progress heartbeat for the whole mode-aware window) or at the 24-hour safety ceiling; pinned runner/chain runs keep the legacy hard TTL (default 20 minutes). Expired runs are markedfailedwith error coderun_timeout.
Cancel — field reference
POST/api/v1/responses/{id}/cancel (cancelResponse)
Cancel an in-flight response
Parameters
| Field | Type | Req | Description |
|---|---|---|---|
| id | path: string | yes | — |
Response
| Field | Type | Req | Description |
|---|---|---|---|
| id | string | — | — |
| object | "response" | — | — |
| created_at | integer | — | Unix seconds |
| status | string completed | failed | in_progress | requires_action | cancelled | — | — |
| required_action | object | — | Present when status is requires_action — owner secrets only (MFA/OTP/text/confirm). Never emitted for captcha / bot challenges. Visuals (live_view / screenshot / replay_url) are included when available. |
| required_action.type | "submit_input" | — | — |
| required_action.submit_input | object | — | — |
| required_action.submit_input.kind | string otp | text | confirm | — | — |
| required_action.submit_input.prompt | string | — | — |
| required_action.submit_input.created_at | integer | — | — |
| required_action.submit_input.channel | string totp | sms | email | unknown | — | Best-effort delivery channel inferred from the prompt. |
| required_action.submit_input.message | string | — | Human-readable ask (same as prompt). |
| required_action.submit_input.submit | object | — | How to send the code. |
| required_action.submit_input.live_view | string | — | — |
| required_action.submit_input.screenshot | string | — | — |
| required_action.submit_input.replay_url | string | — | — |
| incomplete_details | object | — | — |
| model | string | — | — |
| confidence | number | — | — |
| output | object[] | — | — |
| output_text | string | — | ALWAYS a plain string, on every runner: freeform answers verbatim; structured results (including output_schema-normalized ones) as pretty-printed JSON. The structured value itself is under banana_peel.output. Guaranteed never a character-indexed map ({"0":"T","1":"h",…}) and never double-encoded JSON-in-a-string — outputs are repaired/unwrapped at the canonical normalization choke point before the envelope is built or persisted. |
| usage | object | — | Approximate tokens (char/4); billing is USD under banana_peel.billing |
| usage.input_tokens | integer | — | — |
| usage.output_tokens | integer | — | — |
| usage.total_tokens | integer | — | — |
| metadata | object | — | — |
| error | object | — | — |
| error.code | string | — | — |
| error.message | string | — | — |
| error.remediation | string | — | — |
| banana_peel | object | — | Canonical banana_peel.run/v1 — superset over all runners. `runner` is the executed runner slug; `routing` is what you requested; `engine` is the execution backend when distinct. |
| banana_peel.schema | "banana_peel.run/v1" | — | — |
| banana_peel.id | string | — | — |
| banana_peel.status | string succeeded | failed | running | blocked | empty | needs_human | cancelled | — | — |
| banana_peel.progress | object | — | Live in-flight visibility (only while status is in_progress / requires_action): runner attempts so far, current Learning batch, and runner spend to date. Superseded by routing_attempts on the terminal object. |
| banana_peel.progress.attempts | object[] | — | — |
| banana_peel.progress.attempts_started | integer | — | — |
| banana_peel.progress.attempts_finished | integer | — | — |
| banana_peel.progress.batch | object | — | Present for Learning-mode batch probes (1-based current batch). |
| banana_peel.progress.spend_usd | number | — | Runner spend so far (pre-fee). |
| banana_peel.progress.updated_at | string (date-time) | — | — |
| banana_peel.required_action | object | — | MFA/OTP pause mirror (prompt/kind only; never the secret). |
| banana_peel.model | string | — | — |
| banana_peel.routing | any | — | — |
| banana_peel.routing_note | string | — | — |
| banana_peel.ranked_runners | string[] | — | — |
| banana_peel.parent_id | string | — | Present on long-task sub-runs only — the parent long task (lt_…). See GET /v1/long-tasks/{id}/runs. |
| banana_peel.entity_id | string | — | Present on long-task sub-runs — the manifest entity this run serves (__enumeration__ for the enumeration step). |
| banana_peel.role | string enumeration | entity_fetch | verification | — | Role of a long-task sub-run within its parent. verification = a sampled spot-check confirming randomly chosen claimed entity ids exist. |
| banana_peel.routing_summary | object | — | Compact "why was it routed there" summary — present on every brain-routed run (routing: "smart"/category and forced-runner decisions the brain recorded; absent on pinned/chain/learning runs, which have no decision to explain). In-flight polls carry the honest base (planned primary + decision reason); the terminal object adds per-attempt fault attribution, the policy version, and a budget-widening echo. The `summary` sentence is redacted before it leaves the API. The same object is embedded in response.completed / response.failed webhook payloads (data.banana_peel.routing_summary). For the full decision card (candidate EU table, site defense, widening steps + stop reasons) follow `explanation` — GET /v1/responses/{id}/explanation. |
| banana_peel.routing_summary.runner | string | — | Executed runner (terminal) or the planned primary (in-flight). |
| banana_peel.routing_summary.strategy | string | — | explore | exploit | forced | fallback (additive enum; widened runs report explore). |
| banana_peel.routing_summary.reason | string | — | Machine reason code: exploit_expected_utility, exploit_recent_winner, explore_cold_domain, explore_low_confidence, explore_floor, explore_streak_breaker, explore_budget_widening, forced_by_caller, fallback_static_chain, … (additive enum). |
| banana_peel.routing_summary.summary | string | — | One redacted plain-words sentence — the same headline the console decision card shows. |
| banana_peel.routing_summary.policy_version | string | — | Routing policy version that made the decision (terminal runs). |
| banana_peel.routing_summary.attempts | object[] | — | Executed attempts in dispatch order. |
| banana_peel.routing_summary.widening | object | — | Present when the run widened past the planned candidate set (budget-governed widening). |
| banana_peel.routing_summary.explanation | string | — | Path to the full decision card: /api/v1/responses/{id}/explanation. |
| banana_peel.runner | string | — | Executed runner slug |
| banana_peel.engine | string | — | Execution backend behind the runner when distinct (e.g. browser-use-cloud) |
| banana_peel.url | string | — | — |
| banana_peel.output | any | — | Run result in canonical shape, identical across runners: a plain string for freeform tasks, or a structured object/array when the runner returned real JSON (or output_schema was requested). Normalized at a single choke point before persist + envelope build: char-exploded strings ({"0":"T","1":"h",…}) are reassembled and JSON-encoded strings unwrapped, so the shape never varies by runner. With an output_schema this is a HARD CONTRACT: a terminal success populates exactly the schema keys, or the run is failed with error.code schema_violation. |
| banana_peel.answer | string | — | GUARANTEED READ PATH (stable default envelope): best-effort distilled answer string, always present as a key. Runner outputs name their keys differently run to run (current_outstanding_balance vs result vs prose-in-summary) — `answer` is the stable projection, so clients without an output_schema never chase keys. Null exactly when answer_present is false. For legitimately empty results the answer states the empty result (e.g. the "No Order History Found" evidence); for download tasks it lists the delivered files. |
| banana_peel.answer_present | boolean | — | Delivery-verdict bit: was the REQUESTED ANSWER actually delivered? True on every genuine success — including a legitimate empty/zero result ("0 orders" IS the answer; empty-result ≠ failure). False on failed/blocked runs and on hollow completions (output that only narrates login/click actions without the requested value — those are status failed, never billed-and-claimed-successful). Meaningful on terminal states; false while still running. |
| banana_peel.evidence | object | — | Evidence trail backing `answer`: verdict reason, final URL, artifact names. |
| banana_peel.evidence.quality_reason | string | — | — |
| banana_peel.evidence.final_url | string | — | — |
| banana_peel.evidence.artifacts | string[] | — | — |
| banana_peel.error | object | — | — |
| banana_peel.quality | object | — | Delivery verdict from the honesty layer (deterministic checks + QA grader): did the run deliver the requested answer? Terminal `status` follows this verdict — not the runner’s mechanical exit code — and the routing scoreboard/brain learn from the same verdict-based labels. |
| banana_peel.quality.verdict | string succeeded | blocked | failed | empty | — | — |
| banana_peel.quality.confidence | number | — | — |
| banana_peel.quality.reason | string | — | — |
| banana_peel.quality.source | string deterministic | grader | — | — |
| banana_peel.quality.answer_present | boolean | — | Mirrored to banana_peel.answer_present (see there). |
| banana_peel.deliverables | object | — | Delivery verdict for file-deliverable tasks: claimed downloads vs files actually captured as retrievable artifacts. Null for text-only tasks. A run that claims downloads but delivers zero captured files is demoted to failed (verdict not_delivered) — in-session download URLs expire with the browser session and are not deliverables. In structured output, `download_succeeded` is renamed `download_triggered` (the in-session click) with a sibling `file_delivered` boolean (actual capture). |
| banana_peel.deliverables.claimed | integer | — | Files the run output claims were downloaded. |
| banana_peel.deliverables.delivered | integer | — | File artifacts actually captured and retrievable. |
| banana_peel.deliverables.verdict | string delivered | partial | not_delivered | — | — |
| banana_peel.deliverables.note | string | — | — |
| banana_peel.block_reason | string | — | — |
| banana_peel.timing_ms | number | — | — |
| banana_peel.cost | object | — | — |
| banana_peel.normalization | object | — | Output normalization echo — present whenever normalization was requested. `applied: true` means the reshape pass executed (and the +$0.10 add-on was billed); `applied: false` includes a short `reason` and `addon_usd: 0` (never charged for a pass that did not run, and never charged when the output_schema contract was violated — see error.code schema_violation). |
| banana_peel.normalization.requested | true | — | — |
| banana_peel.normalization.applied | boolean | — | — |
| banana_peel.normalization.reason | string | — | Why the pass did not apply; null when applied. |
| banana_peel.normalization.addon_usd | number | — | Add-on billed for this run (0.1 when applied, else 0). |
| banana_peel.custody | object | — | Custody enforcement echo — present whenever a credential custody policy applied to this run (vault credential and/or request `custody`). The runner candidate set was hard-filtered to `allowed_runners` BEFORE routing; no fallback, race, wave, or learning probe left that set. Persisted on the run record for audit. |
| banana_peel.custody.policy | string | — | Composed policy label (any | trusted_runners | deck_only | pinned:<runner> — joined with + when composed). |
| banana_peel.custody.source | string vault | request | vault+request | — | — |
| banana_peel.custody.credential_id | string | — | Vault credential id; null for inline credentials. |
| banana_peel.custody.allowed_runners | string[] | — | Effective allowed-runner set after the filter (capped at 40 slugs). |
| banana_peel.custody.allowed_runner_count | integer | — | — |
| banana_peel.custody.candidates_before_filter | integer | — | — |
| banana_peel.custody.enforced | true | — | — |
| banana_peel.session | object | — | Metadata about the browser this run used (live view, replay, CDP connect). A record of the run’s browser — not a standalone session you manage. Unused fields are null. |
| banana_peel.session.id | string | — | — |
| banana_peel.session.provider_id | string | — | — |
| banana_peel.session.live_url | string | — | — |
| banana_peel.session.replay_url | string | — | — |
| banana_peel.session.connect_url | string | — | — |
| banana_peel.session.selenium_remote_url | string | — | — |
| banana_peel.session.region | string | — | — |
| banana_peel.session.status | string | — | — |
| banana_peel.live_view | string | — | — |
| banana_peel.replay_url | string | — | — |
| banana_peel.screenshot | string | — | — |
| banana_peel.screenshot_unavailable_reason | string | — | — |
| banana_peel.artifacts | any[] | — | — |
| banana_peel.artifacts[] | object | — | A file captured from the run (downloads, screenshots, recordings). Captured artifacts are hosted by Banana Peel: `url` points at the authenticated retrieval endpoint (GET /v1/responses/{id}/artifacts/{artifactId}, same API-key auth), which 302-redirects to a short-lived signed URL (~10 min TTL) or streams the bytes. Artifacts follow the run retention window (default 7 days) and are purged on response delete / account deletion. Per-file capture cap is 30 MB; up to 10 artifacts per run. When bytes could not be captured, `url` falls back to the provider-hosted URL (typically session-gated and short-lived) and `unavailable_reason` says why. |
| banana_peel.steps | object[] | — | — |
| banana_peel.fallback_from | string[] | — | — |
| banana_peel.act_script | array | — | — |
| banana_peel.created_at | string (date-time) | — | — |
| banana_peel.finished_at | string (date-time) | — | — |
HTTP statuses
200— Response object (status: cancelled, or unchanged if already terminal)401— Invalid API key404— Not found
Generated from OpenAPI. Do not hand-maintain this table.
Validation fails free
Structurally unusable requests are rejected with a 400 and a machine-readable error.code before any runner attempt starts, so nothing bills: missing_input (no task text or URL), missing_url (routing: "learning" / "learning-all" without a resolvable target URL — Learning modes need a domain to teach the brain against), invalid_output_schema / missing_output_schema (normalization requested but unusable). Prose-only tasks where the runner finds the site itself remain valid on all other routing modes.
MFA / OTP / human input
Full guide: MFA / OTP. When a target site asks for MFA, 2FA, or an OTP, the run pauses immediately and asks the user for the code. It does not keep trying other runners.
CAPTCHA and other bot-detection gates (reCAPTCHA, hCaptcha, Turnstile, DataDome, Cloudflare challenge, “prove you are human”) are solved, re-routed, or end blocked with reason bot_challenge. They never become a human text prompt.
Poll GET /api/v1/responses/:id until status is requires_action (or handle the response.requires_action webhook). MCP create_response waits until that pause and returns human_action_required plus required_action. Use background: true so you receive the id immediately, then poll and answer:
curl -s "$BANANA_PEEL_BASE/api/v1/responses" \
-H "Authorization: Bearer $BANANA_PEEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "banana-peel",
"input": "Log into https://example.com and download the latest invoice",
"routing": "smart",
"background": true,
"credential_id": "cred_…"
}'
# → { "id": "resp_…", "status": "in_progress", … }- Outer status
requires_action+required_action.type = submit_input(kind:otp|text|confirm,channel: totp | sms | email | unknown, plussubmit.method/submit.path). - Submit the code with POST /api/v1/responses/{id}/input { "code": "123456" } (or { "answer": "…" } for text/confirm). MCP: submit_input({ id, code }). Then keep polling until completed or failed. The secret is not echoed and is cleared after the agent consumes it.
- If the vault credential (or request totp_secret) includes a base32 TOTP seed, OTP asks are answered in-process and the run does not pause. Username/password alone is not enough — the run still pauses for the code.
allow_interactionhas three effective states (the wire format stays a boolean). Omitted: interaction is permitted whenever the executing runner supports the relay — runs on non-relay runners still work and simply never pause.false: never interact — a run that hits an MFA wall ends with a blocked verdict instead of pausing for input.true: interaction capability is REQUIRED — the run declares it will need mid-run input, and runners that cannot relay questions (hosted vendor agents executing on their own infrastructure) are refused up front rather than running to a dead end.- For login flows end to end (vault credentials, TOTP, what pauses and what doesn't), see Auth flows. At production scale, register outbound webhooks and handle
response.requires_actioninstead of spinning GET — polling still works.
Submit input — field reference
POST/api/v1/responses/{id}/input (submitResponseInput)
Submit MFA/OTP or text for a paused run
Parameters
| Field | Type | Req | Description |
|---|---|---|---|
| id | path: string | yes | — |
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| code | string | — | OTP / MFA code |
| answer | string | — | Generic text / confirm reply |
| input | string | — | Alias of answer |
HTTP statuses
200— Accepted — keep polling GET /v1/responses/{id}400— Missing code/answer409— Run is not waiting for input
Generated from OpenAPI. Do not hand-maintain this table.
The full request/response contract is in the OpenAPI spec; what each runner can return is in the capability matrix.