Changelog
What changed, and when.
Browser fetch: a page's own data, with no model (October 2026)
POST /v1/users/{user_id}/browser/fetchopens one page in a background tab of the user's own browser, with its sign-ins, works yoursteps(click,type,scroll,wait_for,page_fetch) and answers with the bodies of the page's own data calls that matchcapture. No model runs.- Only sites in the deployment's
browser.allowed_domainsopen (403 domain_not_allowed), and never a page on itsbrowser.blocked_paths, such as a payment page (403 page_blocked, which leaves abrowser.page_blockedrow inGET /v1/audit-log: the host and path, never the query). keep_open_mskeeps the tab, named bytab, for the next fetch, which sends nourl.front: trueopens 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 itscrop, 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 is504 timeoutwith what came inpartial.GET /v1/users/{user_id}/browser/watchstreams the fetch's tabs live, read only; it never wakes the computer. A fetch'stagnames its tab there.- An optional
clicktakeswithin_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
clicktakesnth(which match, from 1) andnear_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 afrontpage.- A session that only watches (
screen: "view") shows nothing while its screen is private: a person signs in on that computer (afrontfetch, from its start until its tab closes), or aprivatefetch runs or keeps its tab (a later fetch on that tab counts too).GET /v1/sessions/{session_id}answersscreen_paused: true,GET /v1/sessions/{session_id}/screenanswers409 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}/screenshotanswers409 browser_in_usethen too. Acontrolsession, the person's own, is unchanged. front_crop(role,nameortext): 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 afrontfetch's start until the element is on the page,GET /v1/sessions/{session_id}/screenanswerscrop_pending: trueand 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 firesvestra-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'scropas soon as it comes. POST /v1/sessionstakesdriverwithscreen: "control":useropens 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 is400.- 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)
approvalslists 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 whatapprovalslists 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_requestand the rest of the box's catalog) is refused with400 tool_name_reservedwhen 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:rememberis 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-serversrefuses it with400 tool_name_reservedonslug, 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 ofmax_turns(it used every turn it had),max_budget(it reached a budget cap),credits_exhausted(the account's credits ran out) orhook_blocked(a policy hook in your company's rules stopped it), andmessage, more about why in plain words (forhook_blocked, the hook's own words;nullwhen there is no more). The first two used to arrive astask.failedwith thatreasonandmessage: null. - Credits running out has its own reason,
credits_exhausted, ontask.stopped, whether the account was out of credits before the work started or ran out while it ran. It used to arrive astask.failedwithmax_budget(orcredits_exhausted), or as a stop with no reason;max_budgetnow 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.stoppedortask.failedas 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 andinit's interview say why a stopped work stopped when the event says.
The one yes shows the price (October 2026)
vestra initnow 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'sextend_rentaltakesrental_id,charge_usdandnew_due_back. Withrental_idalone, 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 itread_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 endsand N more:extend_rental: rental_id R-1042, charge_usd 15, new_due_back 2026-10-05 18:00where it used to read... extend_rental: rental_id R-1042, charge_us….details.rowsis unchanged: the call's first 8 arguments, each value up to 200 characters. - The tests
vestra initandvestra test --generatedraft 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:withfunction:, 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: truefor a tool that changes nothing (a lookup), andrun_without_asking: truefor 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 initmarks the lookups of a new file for you. In a file you already have, add the line yourself under each lookup, such asget_booking:read_only: true, thenvestra deploy. - The wait is a real approval:
interaction.createdof typeapproval, whosesummarysays 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 whosedetailscarrytool_nameand the arguments asrows. A company MCP tool's approval reads the same way: itssummaryused 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 usualtool_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 itstool_resultsentry answers the call, by the function's name. moves_moneyon a company action's card (a function of yours, or a tool of your MCP servers) isnull, meaning unknown: your app knows what your tools cost, so show the price your own way. It used to sayfalse, even for a function that charges. The field is nowboolean | nullin the contract and both SDKs; every other card is unchanged.
init round-trips (October 2026)
GET /v1/deployments/{id}?format=yaml, and the testsvestra test --generateadds 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 filevestra initwrote is "nothing changed" from every SDK and the CLI. A key namedon, such as the routine's ownon: call, stays as it is.- The Python SDK reads
vestra.yamlby 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, and010is ten. 1_000and0b101are words;1e3,6E10,+.5and0o17are numbers.- Only
trueandfalseare booleans (yes,onandoffare words, as before). - A
<<key no longer merges another mapping: it is a key like any other.
- A time (
- 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.initin 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 sendsuser.created. - No
user_idmeans 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 showsuser_id: null. PATCH /v1/orgtakesrequire_user: trueto refuse a request withoutuser_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.statuson the stream, withturn_idandturn_status(running,working,requires_action,completed,failed,stopped). A turn that settles (completed,failed,stoppedorrequires_action) also sends it as a webhook, with the turn'sdata,outcome,errorandusage. Itsusage.cost_completeis true when every charge of the turn and its work was in as it settled; when false, the rest arrives onusage.updatedand the task endings. - An answered client tool call sends
interaction.resolved; one that timed out sendsinteraction.expired. Expiry events come for tool calls only, for now: other waits end ininteraction.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/sessionsanswers{ 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_originsdecides only which of your pages may frame a session. With none, a session still opens: its page frames nowhere, and itsurlworks 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) needsPATCH /v1/org { "memory_panel": true }. - The ids
emb_,bs_andvs_are gone;ses_is new. session.endedcarriessession_idinstead ofvoice_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 itsconversation_id.GET /v1/eventstakesafter=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.yamldeclares 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, ishttps://vestra.ai/developers/vestra.schema.json(openapi/vestra.schema.jsonin 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 answerschanged: []; 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 }, andnew_webhooksshows 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.nowpromotes at once.nopromotes 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 aretest_run.completed, thendeployment.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_missingwitherror.names: store them withPUT /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_sharedwitherror.deploymentsand 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: usruns on your company's own Bash. A routinefor: each customeris 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=yamlanswers the latest version asvestra.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_idandmanaged_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: errorwitherror_code: interrupted, and sends itstest_run.completed. It promotes nothing: apply the file again (an unchanged file runs its tests again) or start the run again.test_run.completednow carrieserror_code, which is null unless the status iserror. - A reply cut off on our side fails fast. A turn whose server stops fails with
process_lostabout 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
denyandrequire_approvalinvestra.yamlnow 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 asmanifest.mcp_tool_policy, by server id, andPOST /v1/deployments/{name}/versionstakes it too. - The tool policy on the server itself (the
toolsofPOSTandPATCH /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_sharedis 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, andDELETE, answer409 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/mcpoffers two more tools:init, which runs the setup interview a reply at a time and needs a test key, andtest, which runs a deployment's scenarios and waits up to 45 seconds for the verdict. It never deploys: in your editor,vestra mcpoffersdeployfrom your own computer.- The TypeScript and Python SDKs and the CLI gain
init,deployandtest, sovestra.yamlis written, applied and tested from every door. The CLI'svestra devdeploys 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 longerOrchestrator reply, and a refund keeps Bash's name (Refund: work by Bash). These words are for people: branch oncategory, 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
neverline forbids offering or attempting that action; otherwise it is asking first, whatever raised the ask (approvals.always, an MCP server'srequire_approval, a browser action). Tests drafted byvestra initandvestra test --generatefor a setup whose instructions forbid an action carry theneverline "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 ownneverline 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 whenrequire_approvallisted it. Bash takes the read-only mark on your server's word, so mark only tools that change nothing. run_without_askingin an MCP server ofvestra.yamllists the tools that run without the yes. A version shows it inmanifest.mcp_tool_policy, besidedenyandrequire_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_approvalstill asks, even before a tool your server marks read-only, anddenystill 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 itsserverindetails, notmcp_invoke. approvalsin 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 undertools:runs your own code, so the system makes no such claim for it.vestra initsays 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_resultsentry 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
neverlines. tool_resultstakes<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'sinteraction.createdfor such a call names itsserver: it is the test's to answer, so your app ignores it.- Drafted tests give
tool_resultsfor 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.