BashBrainTeammatesPricingDevelopersGet a test key

Changelog

What changed, and when.

Browser fetch: a page's own data, with no model (October 2026)

  • POST /v1/users/{user_id}/browser/fetch opens one page in a background tab of the user's own browser, with its sign-ins, works your steps (click, type, scroll, wait_for, page_fetch) and answers with the bodies of the page's own data calls that match capture. No model runs.
  • Only sites in the deployment's browser.allowed_domains open (403 domain_not_allowed), and never a page on its browser.blocked_paths, such as a payment page (403 page_blocked, which leaves a browser.page_blocked row in GET /v1/audit-log: the host and path, never the query).
  • keep_open_ms keeps the tab, named by tab, for the next fetch, which sends no url. front: true opens it in front of the person's window instead, as a phone (its size, user agent and touch), for a sign-in they finish themselves; other fetches run behind it, a session's screen carries its crop, and neither the browser watch nor the screenshot (409 browser_in_use) ever shows its page. Four run at once (409 browser_busy), a person using the browser comes first (409 browser_in_use), and a slow page is 504 timeout with what came in partial.
  • GET /v1/users/{user_id}/browser/watch streams the fetch's tabs live, read only; it never wakes the computer. A fetch's tag names its tab there.
  • An optional click takes within_ms (up to 10000): it looks that long for an element that shows a moment after the page, such as a site's popup, then goes on without it.
  • A click takes nth (which match, from 1) and near_text (the first match after a text, or after texts in turn).
  • stage: true: in front, one kept tab, alone in the watch.
  • private: true: a fetch the browser watch never shows.
  • front_device: "desktop": a hand-off on the site's desktop page, as the other fetches see it.
  • challenge: "turnstile": the page called Cloudflare's Turnstile check.
  • GET /v1/users/{user_id}/browser/cookies?domain=&names=: which cookies of those names the browser holds for an allowed site, and until when. Names and expiry only, never a value; it opens no page, so read it every few seconds to see a person finish signing in on a front page.
  • A session that only watches (screen: "view") shows nothing while its screen is private: a person signs in on that computer (a front fetch, from its start until its tab closes), or a private fetch runs or keeps its tab (a later fetch on that tab counts too). GET /v1/sessions/{session_id} answers screen_paused: true, GET /v1/sessions/{session_id}/screen answers 409 browser_in_use, and the screen page hangs up and says the screen is private right now, then comes back by itself. GET /v1/users/{user_id}/screenshot answers 409 browser_in_use then too. A control session, the person's own, is unchanged.
  • front_crop (role, name or text): a hand-off's screen shows only that element of its page, such as a site's sign-in dialog, at a phone's size. From a front fetch's start until the element is on the page, GET /v1/sessions/{session_id}/screen answers crop_pending: true and the screen page shows no picture yet; after 20 seconds without it, the page's part shows as before. The screen page also asks again while the computer is still waking (browser_not_open), for up to 30 seconds.
  • A session's screen page checks every 3 seconds whether the session is still open. When your server ends it (DELETE /v1/sessions/{session_id}), the page says it can be closed, tells the page around it (vestra:ended; embed.js fires vestra-ended), tells a window that opened it the same, and closes its own window when a script opened it (a frame never closes itself). A screen opened before its hand-off takes the hand-off's crop as soon as it comes.
  • POST /v1/sessions takes driver with screen: "control": user opens the screen in your user's hands from the first frame the page shows, with no Take control and nothing to give back, for a page that is theirs to use (a sign-in Bash opened for them). bash, or none, is as before: they watch first and tap Take control. driver: "user" with any other screen is 400.
  • A front_device: "desktop" hand-off now runs at a phone's shape, 440 wide and as high as the window, still with the site's desktop page: a sign-in service's tab it opens draws into the same part of the screen, so a session's screen shows one rectangle whatever is in front. On a hand-off's page (a crop) the screen page has no bar: the person's first tap on the page takes control and opens the keyboard.

What approvals enforces, said plainly (October 2026)

  • approvals lists what Bash asks the person before doing. The manifest and file references, the guides and the agent skill said it lists what "always waits for the person's yes", which only a system approval makes true, and not every path has one yet. A system approval enforces what approvals lists for your own functions and your MCP servers' tools that wait for a yes; in a browser or a connected app, Bash is instructed to ask.
  • Nothing changes in how Bash behaves: the docs now say what it does. When a system approval covers every path, this changelog says so.

Bash's own names are reserved (October 2026)

/v1 is not public yet, so this changes the contract on purpose.

  • A function tool named like one of Bash's own tools (execute_code, mcp_invoke, github_pull_request and the rest of the box's catalog) is refused with 400 tool_name_reserved when a version is saved (a file or a manifest) or a message brings it. Bash skipped such a function before, keeping its own tool, so the call you meant never happened. The check is exact: only Bash's own names, never a pattern. The names are reserved whether or not a deployment uses that capability: remember is refused even for a company whose memory is off, and a tool behind a feature not yet on for you is reserved all the same, so turning it on never breaks a saved version.
  • An MCP server's slug can't be one of Bash's own tool servers (composio, browser-control, os-hands, vestra): POST /v1/mcp-servers refuses it with 400 tool_name_reserved on slug, a file never registers one, and a new version naming a server made before this (directly, or by its URL in a file) is refused the same way. Register the server again under another slug and point your next version at it (a file then uses the new one); don't delete the old one while a live version still runs it.
  • Nothing already saved changes: a version saved before this keeps running, Bash's own tool answering to the name. Only a new version, a new server or a new message is checked.

A limit's stop is a stop (October 2026)

/v1 is not public yet, so this changes the contract on purpose.

  • Work that a limit or a policy stopped now ends with task.stopped, as the conversation's own record already said, and says why in two new fields: reason, one of max_turns (it used every turn it had), max_budget (it reached a budget cap), credits_exhausted (the account's credits ran out) or hook_blocked (a policy hook in your company's rules stopped it), and message, more about why in plain words (for hook_blocked, the hook's own words; null when there is no more). The first two used to arrive as task.failed with that reason and message: null.
  • Credits running out has its own reason, credits_exhausted, on task.stopped, whether the account was out of credits before the work started or ran out while it ran. It used to arrive as task.failed with max_budget (or credits_exhausted), or as a stop with no reason; max_budget now means only a budget cap (a task's limit, a turn's spend ceiling, or the day's budget).
  • A stop by you or a person carries neither field, exactly as before. Wait on task.stopped or task.failed as you already do: only the event a limit's stop arrives as has moved.
  • Both SDKs type the two fields on task.stopped (optional), and the events reference lists them. The CLI's event lines and init's interview say why a stopped work stopped when the event says.

The one yes shows the price (October 2026)

  • vestra init now gives a function that waits for your customer's yes each value the customer agrees to as an input, so its approval shows them: a bike rental's extend_rental takes rental_id, charge_usd and new_due_back. With rental_id alone, its approval read "charges its daily_price_usd" with no number. Init asks what the customer agrees to and where Bash finds it, declares those values as required inputs named and written like your example results, and writes instructions that say where each comes from ("call extend_rental with charge_usd set to daily_price_usd from get_rental"). When no lookup returns a value (your code works the price out as it acts), init asks for one that does, such as a quote, and declares it read_only: Bash never guesses a value for the approval.
  • Your code checks those values against its own before it acts, and throws when one differs, so nothing is charged that the customer did not see. Init's closing words say so for each such function, naming its inputs, and for one whose approval shows only what it acts on, they say to add the values the customer should see. A function you declared before keeps its inputs: add the values yourself, and the check in your code.
  • A company action's summary (at most 120 characters) never cuts an argument any more. Short of room, it leaves out the description's first sentence, then each argument that does not fit, and ends and N more: extend_rental: rental_id R-1042, charge_usd 15, new_due_back 2026-10-05 18:00 where it used to read ... extend_rental: rental_id R-1042, charge_us…. details.rows is unchanged: the call's first 8 arguments, each value up to 200 characters.
  • The tests vestra init and vestra test --generate draft check what the approval shows (The approval shows the charge of 12 and the new return time 2026-10-07 17:30.), never what Bash says before it: Bash may go from its lookup straight to the approval. The starter test of the customer's no is kept once drafted, whatever a check of its wording finds.

A test never makes a routine (October 2026)

  • In a test run, a routine the test customer asks for is never made: Bash answers as it would for a real one, with when it would run, but nothing is scheduled and nothing runs later. Asked for their routines, the test customer sees only the ones made in that test, never another test's or an earlier run's. Your real customers' routines are unchanged.

Your functions wait for a yes (October 2026)

  • A function your code answers (tools: with function:, or a tool an ask declares) now waits for the customer's yes before its call reaches your code, as every tool of your MCP servers already does. Two new fields let one run at once: read_only: true for a tool that changes nothing (a lookup), and run_without_asking: true for one that changes something that needs no yes. Both are optional and default to false; setting both is fine. The SDKs' tool() takes them too.
  • Mark your lookups read_only: true. Every version saved before this change marks none, so each of its functions, lookups included, now waits for a yes: there is no shim. vestra init marks the lookups of a new file for you. In a file you already have, add the line yourself under each lookup, such as get_booking: read_only: true, then vestra deploy.
  • The wait is a real approval: interaction.created of type approval, whose summary says what will happen, from the first sentence of the tool's description, then names the tool and each argument (Extends a rental by one more day. extend_rental: rental_id R-1042, at most 120 characters), and whose details carry tool_name and the arguments as rows. A company MCP tool's approval reads the same way: its summary used to be the description alone, lowercased, with two arguments; it now leads with the description as written, then <tool>: <every argument>. On a yes your code gets the usual tool_call; on a no the function never runs, and Bash is told the customer declined.
  • In a conversation, Bash starts work to call a function that waits, and the work asks first. A function marked either way is still called at once. A routine, a batch or an ask with no person in your app has nobody to say yes, so a function it calls waits until the approval expires: mark one that may run there run_without_asking: true.
  • The yes to a company action (a tool of your MCP servers, or a function of yours that is not read-only or run without asking) needs present_user: true, sent for the person who saw it, as a payment's does: without it, 400 present_user_required. A no never needs it.
  • In a test, the test customer says no to a function's approval, unless the test sets approves: true; then its tool_results entry answers the call, by the function's name.
  • moves_money on a company action's card (a function of yours, or a tool of your MCP servers) is null, meaning unknown: your app knows what your tools cost, so show the price your own way. It used to say false, even for a function that charges. The field is now boolean | null in the contract and both SDKs; every other card is unchanged.

init round-trips (October 2026)

  • GET /v1/deployments/{id}?format=yaml, and the tests vestra test --generate adds to your file, quote any word a YAML 1.1 reader would read as another type: a time such as "10:15" (YAML 1.1's base-60 number 615), "yes", "010", "1_000", a date. Deploying the file vestra init wrote is "nothing changed" from every SDK and the CLI. A key named on, such as the routine's own on: call, stays as it is.
  • The Python SDK reads vestra.yaml by YAML 1.2's rules, as the TypeScript SDK and the CLI do. In a file you wrote by hand:
    • A time (10:15) and a date stay words, and 010 is ten.
    • 1_000 and 0b101 are words; 1e3, 6E10, +.5 and 0o17 are numbers.
    • Only true and false are booleans (yes, on and off are words, as before).
    • A << key no longer merges another mapping: it is a key like any other.
  • A file you deployed through the Python SDK 0.1.3 with an unquoted time from 10:00 on went up with that time as a number. Your next deploy sends it as written, so it makes one new version.
  • A reply in a deployment interview (POST /v1/conversations/{id}/messages) goes straight to the interview's work, which asks its next questions in its turn-ending words or through a question interaction. init in each SDK also shows a question the interview asks in its own reply, so it never waits on words already said.

One card, train 8a (October 2026)

/v1 is not public yet, so these changes break the contract on purpose: old routes and event names are removed, not aliased.

Whose Bash

  • Every request that acts on a Bash takes user_id, your id for the person (1 to 128 characters, starting with a letter or digit). The first request that names a user creates them, and sends user.created.
  • No user_id means your company's own Bash: one per company and mode, with the company's memory mode. It is never named in a view: a conversation, task, file, interaction or event on it shows user_id: null.
  • PATCH /v1/org takes require_user: true to refuse a request without user_id (400 user_required).
  • A batch item with no user runs on the company's own Bash, at most 5 at a time per company.

ask

Before Now
POST /v1/users/{user_id}/conversations with { message: { message, ... } } POST /v1/conversations with { user_id, message, ... }: the first message's options sit beside it
POST /v1/conversations (headless, the deployment's service user) POST /v1/conversations with no user_id: the company's own Bash, memory on
GET /v1/users/{user_id}/conversations GET /v1/conversations?user_id= (no user_id: every conversation of the company)

POST /v1/conversations and POST /v1/conversations/{id}/messages take stream: true: the answer is that turn's events as text/event-stream (201 for a create), ending when the turn completes, fails, stops or waits on you. A retried request with the same Idempotency-Key gets the stored JSON instead; resume with GET /v1/conversations/{id}/events and Last-Event-ID.

answer

Before Now
POST /v1/interactions/{id}/resolve with { decision, answers, nonce, present_user, message } POST /v1/interactions/{id}/answer
POST /v1/conversations/{id}/tool-results with { call_id, output } POST /v1/interactions/{call_id}/answer with { output } or { error }
GET /v1/conversations/{id}/tool-calls GET /v1/interactions?type=tool_call&conversation_id=
GET /v1/users/{user_id}/interactions GET /v1/interactions?user_id=

An answer's body fits the item's type: an approval takes { "approve": true } or { "approve": false } (with present_user, message, nonce), a question { "answers": [...] } or { "approve": false }, a connection { "approve": false }, a hand-off { "done": true }, { "could_not": true } or { "not_now": true } (or { "answers": [...] } when it asks for a value, such as a code), a client tool call { "output": ... } or { "error": "..." }. Any other body is 400 answer_mismatch, naming the shapes that fit. The answer is { id, object: "interaction", type, status }. An interaction's type can be tool_call, and its task_id is null when Bash made the call while replying, outside any piece of work.

Events

One catalog names every event, on the stream and in webhooks.

Before Now
conversation.replied (webhook, when the turn settled) message.completed (stream and webhook, when the reply's text is complete, with usage: { turn_cost_usd, conversation_total_usd, cost_complete })
work.started, task.created task.started (stream and webhook)
progress.updated, activity, task.status task.progress, with data.progress: step or status
work.completed, task.status with stopped task.completed, task.failed, task.stopped
tool_call.required, conversation.requires_action interaction.created with type: "tool_call", one per call (it was one conversation.requires_action per turn)
browser.control screen.control
voice_session.ended session.ended
  • Every turn change is a conversation.status on the stream, with turn_id and turn_status (running, working, requires_action, completed, failed, stopped). A turn that settles (completed, failed, stopped or requires_action) also sends it as a webhook, with the turn's data, outcome, error and usage. Its usage.cost_complete is true when every charge of the turn and its work was in as it settled; when false, the rest arrives on usage.updated and the task endings.
  • An answered client tool call sends interaction.resolved; one that timed out sends interaction.expired. Expiry events come for tool calls only, for now: other waits end in interaction.resolved.
  • Stored stream events, the event log and your webhook endpoints' event filters were moved to the new names. Events already sent keep the data they were sent with.

One card, train 8b (October 2026)

Like train 8a, these changes break the contract on purpose: old routes are removed, not aliased.

Sessions (watch, talk, embed)

Before Now
POST /v1/users/{user_id}/embed-sessions, POST /v1/users/{user_id}/browser-sessions, POST /v1/users/{user_id}/voice-sessions POST /v1/sessions with { user_id, conversation_id, chat, screen, voice, memory }
DELETE /v1/embed-sessions/{id}, DELETE /v1/browser-sessions/{id}, POST /v1/voice-sessions/{id}/end DELETE /v1/sessions/{id}
GET /v1/voice-sessions/{id} GET /v1/sessions/{id}
POST /v1/voice/connect with the voice token POST /v1/sessions/{id}/voice with { sdp } and the session's token
GET /v1/embed/frame-policy?session= GET /v1/sessions/{id}/frame-policy
/v1/embed/browser/* GET and POST /v1/sessions/{id}/screen
/v1/embed/conversation, /events, /messages, /stop, /interactions/*, /files/* the core routes, with the session's token: GET /v1/conversations/{id}, its messages and events, POST …/stop, GET /v1/interactions/{id}, POST …/answer, GET /v1/files/{id}
GET /v1/users/{id}/browser/screenshot GET /v1/users/{id}/screenshot
  • POST /v1/sessions answers { id: "ses_…", token, url, frame_ancestors, expires_at, … }, and creates the user the first time it sees the id. The token lives 15 minutes and reaches only that user and the session's conversation, and never shows money; anywhere else it answers 404, and it lists no interactions.
  • allowed_embed_origins decides only which of your pages may frame a session. With none, a session still opens: its page frames nowhere, and its url works as a page of its own. A call (POST /v1/sessions/{id}/voice) is taken from Vestra's own page and from your allowed origins.
  • The memory panel (memory: true) needs PATCH /v1/org { "memory_panel": true }.
  • The ids emb_, bs_ and vs_ are gone; ses_ is new.
  • session.ended carries session_id instead of voice_session_id, and is also sent when a session is revoked (end_reason: "revoked").

Flat paths

user_id moves into the body or the query. Leaving it out means your company's own Bash (for secrets, your company's store); the company-wide lists show everyone without it, and an unknown user_id on a read is 404.

Before Now
POST, GET /v1/users/{id}/routines POST, GET /v1/routines
POST, GET /v1/users/{id}/files POST /v1/files?user_id=, GET /v1/files?user_id=&task_id=&conversation_id=&source=
GET /v1/tasks/{id}/outputs GET /v1/files?task_id=
/v1/users/{id}/connections… /v1/connections…
/v1/users/{id}/memory… /v1/memory…
/v1/users/{id}/secrets… and POST /v1/secrets PUT, GET, DELETE /v1/secrets… with an optional user_id
GET /v1/users/{id}/usage GET /v1/usage?user_id=
GET /v1/users/{id}/tasks GET /v1/tasks?user_id=
GET /v1/tasks/{id}/messages GET /v1/tasks/{id}/trace
GET /v1/tasks/{id}/events GET /v1/conversations/{id}/events?task_id=
/v1/webhook-endpoints… /v1/webhooks… (the same nine routes)
  • GET /v1/files?conversation_id= lists the outputs of that conversation's tasks. Every file names its conversation_id.
  • GET /v1/events takes after=evt_… to read forward, oldest first.

Routines

ask replaces message; every takes plain words (weekday at 08:00), read in the user's time zone; on: "call" answers a trigger_url; name is optional. routine.fired now also rides the conversation's stream. Bash can make, list and pause routines when a user asks.

Keys

Keys carry scopes (all, or any of runtime, setup, admin); a key without the scope a route needs answers 403 scope_missing, and a key gives only the scopes it has. Erasing or exporting a user needs admin. GET /v1/me answers every key, whatever its scopes.

Also

Conversation.last_event_seq is new. Idempotency-Key is listed only where it is honoured, and honoured everywhere it is listed.

One card, train 9 (October 2026)

/v1 is still not public, so this train changes the contract where the card needs it.

deploy: the file

  • vestra.yaml declares a deployment whole: instructions, language, model, apps, tools (MCP servers by URL, and function tools your code answers), skills, knowledge, approvals, memory, the computer, routines, limits, tests and webhooks. Its JSON Schema, for editors, is https://vestra.ai/developers/vestra.schema.json (openapi/vestra.schema.json in the repo).
  • PUT /v1/deployments/{name} applies it, sent as JSON. The first call makes the deployment (201). A file that means what the latest version's file means makes no version and answers changed: []; otherwise it saves a version and names the fields that changed. The answer is always { object: "deployment_apply", deployment, version, changed, tests, promote, new_webhooks }, and new_webhooks shows each new endpoint's signing secret once.
    • Until tool policy is stored per version, an MCP server's tool policy is one setting shared by both modes, so a live key's change takes effect when the file is applied, not when the version is promoted.
  • ?promote=after_tests (the default) runs the version's tests and promotes it in the key's mode when they pass: a test key moves the draft, a live key moves live. now promotes at once. no promotes nothing, but it is not a dry run: when the key's mode already runs the version (the same file again, or a new deployment's version 1 with a test key), the webhooks and routines the file declares are still made to match it, as on every apply. The events are test_run.completed, then deployment.promoted, which a test key's promotion now sends too.
  • A file naming a company secret the key's mode does not hold answers 400 secret_missing with error.names: store them with PUT /v1/secrets/{name} and apply again. A refused apply writes nothing.
  • MCP servers are registered by URL and reused: a server with the file's URL is made to match the file. A server's headers and tool settings are the same for every deployment and mode that names it, so when it runs elsewhere with other settings (another deployment's live, draft or newest version, or, for a test key, this deployment's live version), the apply answers 409 mcp_server_shared with error.deployments and writes nothing; a server only this deployment runs can be changed with a live key. A display name alone is never a difference, and an archived deployment holds no server. Give a deployment whose tool settings differ its own server, at another URL.
  • A function tool named like one of Bash's own tools, or whose input is not an object, is refused when a version is saved (400 tool_name_reserved, 400 tool_schema_invalid), in a file or in a manifest, instead of failing every ask afterwards.
  • Tests become the deployment's scenarios. Routines and webhooks the file declares belong to it: an apply that drops one removes it, and nothing made by hand is touched. A routine for: us runs on your company's own Bash. A routine for: each customer is a template: each customer gets their own copy the first time they talk to Bash on this deployment, never all at once, and removing it from the file stops every copy. Copies count toward your company's 200 active routines in each mode; a customer past that gets no copy.
  • GET /v1/deployments/{name}?format=yaml answers the latest version as vestra.yaml, an interview's draft included.
Before Now
/v1/deployments/{deployment_id}/... takes an id takes the id or the name, on every route
POST /v1/deployments/{deployment_id}/versions/{version}/test-runs POST /v1/deployments/{name}/test-runs with { version?, user_secrets? }: no version tests the latest

Webhooks and skills

  • A webhook endpoint shows deployment_id and managed_by. An endpoint a file declares hears its deployment's events and your company's account events only.
  • A company skill shows sha256, the hash of its files as uploaded, computed the way the SDKs compute a folder's, so an unchanged folder is never uploaded again.

One card, train 8c (October 2026)

Nothing in the contract is removed or renamed. Three things now hold on a bad day.

  • A test run whose server stopped now ends. A run that stops reporting for 15 minutes (a deploy or a crash cut it off) ends as status: error with error_code: interrupted, and sends its test_run.completed. It promotes nothing: apply the file again (an unchanged file runs its tests again) or start the run again. test_run.completed now carries error_code, which is null unless the status is error.
  • A reply cut off on our side fails fast. A turn whose server stops fails with process_lost about a second after a deploy replaces the server, and within two minutes when a server stops without warning, instead of after 10 minutes. The conversation takes your next message at once.
  • A database stall no longer loses a reply. An event the server could not write is tried again, and is still written exactly once, its webhook included.

One card, tool policy per version (October 2026)

  • An MCP server's deny and require_approval in vestra.yaml now belong to the version the file makes, not to the server. A file's policy changes only its own version, and takes effect when that version is promoted in a mode: a test key's promotion moves the draft, a live key's moves live. Two deployments on one MCP server keep two policies. A version shows it as manifest.mcp_tool_policy, by server id, and POST /v1/deployments/{name}/versions takes it too.
  • The tool policy on the server itself (the tools of POST and PATCH /v1/mcp-servers) is now a floor. Every deployment, in both modes, runs under it, and a version's own policy adds to it and never removes from it: a tool denied by either never runs, and a tool either one asks about waits for the person, unless one of them denies it.
  • A file no longer writes a server's tool policy. A server a file registers starts with an empty floor. A server a file registered before this change keeps the policy that file gave it, now as its floor, until you change it with PATCH /v1/mcp-servers/{id}.
  • 409 mcp_server_shared is now about headers only: a different tool policy on one URL is never a conflict.
  • A test key can no longer change an MCP server that a deployment's live version uses: PATCH /v1/mcp-servers/{id} that changes its URL, auth or floor, and DELETE, answer 409 mcp_server_live. Change it with a live key, or as an owner or admin. A new name alone is not a change.
  • The first apply of an unchanged file after this change saves one new version when the file gives any MCP server a policy: that version carries the policy. Versions saved before this change carry none of their own and run under the server's, as before.
  • A version with no policy of its own shows the server's floor in its file. Edit and apply that file, and the floor's lists become that new version's own: loosening the floor later does not loosen them.

One card, the doors and the developer site (October 2026)

Nothing in the routes is removed or renamed.

  • /v1/mcp offers two more tools: init, which runs the setup interview a reply at a time and needs a test key, and test, which runs a deployment's scenarios and waits up to 45 seconds for the verdict. It never deploys: in your editor, vestra mcp offers deploy from your own computer.
  • The TypeScript and Python SDKs and the CLI gain init, deploy and test, so vestra.yaml is written, applied and tested from every door. The CLI's vestra dev deploys every save to test mode while you build.
  • The developer site, /developers, has a page for each line of the card, with its routes from the contract and a sample from each door, every one checked in CI. The API reference moved to /developers/reference.
  • A usage line item for Bash's own replies says Reply by Bash, no longer Orchestrator reply, and a refund keeps Bash's name (Refund: work by Bash). These words are for people: branch on category, which is unchanged.
  • A test's judge counts an approval Bash asked for, which the test customer declined, as a broken rule only when a never line forbids offering or attempting that action; otherwise it is asking first, whatever raised the ask (approvals.always, an MCP server's require_approval, a browser action). Tests drafted by vestra init and vestra test --generate for a setup whose instructions forbid an action carry the never line "Offers, attempts or claims to" that action, so a Bash that offers it fails, even when the test customer said no. A test written before keeps its verdict unless its own never line forbids the offer.

One card, a company action asks first (October 2026)

  • Every tool of your MCP servers now waits for your customer's yes before it runs, in live and test mode alike, unless your server marks it read-only (MCP readOnlyHint: true) or your file lets it run without asking. Before, a tool asked only when require_approval listed it. Bash takes the read-only mark on your server's word, so mark only tools that change nothing.
  • run_without_asking in an MCP server of vestra.yaml lists the tools that run without the yes. A version shows it in manifest.mcp_tool_policy, beside deny and require_approval. A tool goes in one of the three lists at most (400 tool_listed_twice), and a name your server does not list is refused when you apply the file (400 tool_not_on_server, naming it and the tools the server lists).
  • require_approval still asks, even before a tool your server marks read-only, and deny still wins over everything.
  • The approval your customer sees takes its words from your server: the tool's description, and whether it may remove or undo something (destructiveHint; left out, it may). Its rows are the call's own arguments. The approval your app receives names the tool itself and its server in details, not mcp_invoke.
  • approvals in your file still lists what Bash asks about on its own. For bookings and cancellations, Bash now also says the system enforces them when they are done with your MCP servers, and only while your file lets none of their tools run without asking. A tool you declare under tools: runs your own code, so the system makes no such claim for it.
  • vestra init says which tools will ask and offers to let some run without asking, and drafted tests expect Bash to ask before a tool that changes something.

A test never really changes anything (October 2026)

  • In a test run, a tool of your MCP servers that your server does not mark read-only never runs on your server, even one your file lets run without asking. Your test's tool_results entry for it, keyed <server>.<tool> (the server's slug, then the name your server lists the tool under), plays its answer; with none, it does not run, and Bash carries on.
  • The transcript says which: "Bash called cancel on your MCP server mcp-tripnest-example; in a test it did not run there, and the test's fixture answered: ...", or "In a test, Bash doesn't run cancel on your server. Add a tool_results entry for mcp-tripnest-example.cancel to play its answer." The judge reads it: the call not running is never Bash's failure, but whether Bash should have made the call at all is still judged by your never lines.
  • tool_results takes <server>.<tool> keys when the version has an MCP server; a key without a dot still has to name a tool you declare (400 tool_not_declared). The test's interaction.created for such a call names its server: it is the test's to answer, so your app ignores it.
  • Drafted tests give tool_results for the tools your file lets run without asking. A read-only tool runs in a test as it does live, and a plain test-key ask (not a test run) still calls your server.