# 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.
