BashBrainTeammatesPricingDevelopersGet a test key

Give Bash your tools

Your tools let Bash reach your systems: a booking, an order, a ticket. Bash decides when to call one, and your code answers it. This uses the help desk from the first guide.

Declare the tool

Bash calls only what your file declares, under a name that is not one of its own tools (execute_code, mcp_invoke or remember, say, refused with 400 tool_name_reserved whether or not your deployment uses that tool), and the description tells Bash when to call it. A tool that changes something waits for your user's yes before its call reaches your code: mark a lookup read_only: true, and a change that needs no yes run_without_asking: true, and either runs at once.

What happens On your next deploy, Bash knows get_booking, which runs at once, and extend_rental, which waits for your user's yes on an approval that shows every input it is called with, so it takes the price and the new return time the user agrees to, never only the rental. Your user reads a description's first sentence on the approval, and drafted tests are written to copy the result it says the tool returns: one example in your code's field names and formats, with made-up values (Returns {customer: 'Maya Chen', due_back: '2026-10-04 18:00'}).

tools.yaml
# In vestra.yaml: tools your code answers. A lookup marked read_only runs at once;# a tool that changes something, left unmarked, waits for your user's yes.# Its approval shows every input it is called with, so it takes what the user# agrees to (the price, the new return time) beside what it acts on.name: travel-helptools:  - function: get_booking    description: Looks up a booking by its reference, and returns the traveller's flights, with their times, seats, baggage allowance and status.    input: { booking_reference: string }    read_only: true  - function: extend_rental    description: Extends a car rental by one day and charges its daily price.    input: { rental_id: string, charge_usd: number, new_due_back: string }

Answer from your code

A function per tool in the SDK, or a module for the CLI. Over HTTP, a call is a tool_call you answer by its id.

What happens What your function returns is the call's output, and a throw is its error, which Bash reads.

Show outputHide output

The CLI prints

  your tool get_booking was called: your module answers it
TypeScript
import { Vestra } from '@vestra/sdk'; // A stand-in for your own booking system: what tools.mjs answers.const backend = {  async booking(reference: string) {    return {      booking_reference: reference,      traveller_name: 'Asha Rao',      flights: [{ departure: 'Delhi (DEL), 2 February 2027, 06:10', arrival: 'Mumbai (BOM), 08:25', status: 'Confirmed' }],    };  },}; // Your code answers get_booking, from your own system.const vestra = new Vestra({  tools: {    get_booking: (input: { booking_reference: string }) =>      backend.booking(input.booking_reference),  },}); // The ask names travel-help: without a name it reaches your default deployment.const traveller = vestra.user('traveller-42');const run = traveller.ask('Asha Rao here. When does my flight on TN-48213 leave?', { deployment: 'travel-help' });console.log(await run.text());
Python
from vestra import Vestra  # A stand-in for your own booking system: what tools.mjs answers.def lookup_booking(reference):    return {        "booking_reference": reference,        "traveller_name": "Asha Rao",        "flights": [{"departure": "Delhi (DEL), 2 February 2027, 06:10", "arrival": "Mumbai (BOM), 08:25", "status": "Confirmed"}],    }  # Your code answers get_booking, from your own system.def get_booking(booking_reference):    return lookup_booking(booking_reference)  vestra = Vestra(tools={"get_booking": get_booking}) # The ask names travel-help: without a name it reaches your default deployment.traveller = vestra.user("traveller-42")run = traveller.ask("Asha Rao here. When does my flight on TN-48213 leave?", deployment="travel-help")print(run.text())
CLI
# Ask as one of your travellers, naming travel-help (without it, an ask reaches your default deployment). Your module answers get_booking when Bash calls it.vestra ask "Asha Rao here. When does my flight on TN-48213 leave?" --user traveller-42 --deployment travel-help --tools ./tools.mjs
HTTP
# Ask as one of your travellers, naming travel-help (without it, an ask reaches your default deployment), and follow the turn as it streams.curl -N "$VESTRA_BASE_URL/v1/conversations" \  -H "Authorization: Bearer $VESTRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "user_id": "traveller-42", "deployment": "travel-help", "message": "Asha Rao here. When does my flight on TN-48213 leave?", "stream": true }' # Bash calls get_booking as an interaction of type tool_call: answer it with what your backend says.curl "$VESTRA_BASE_URL/v1/interactions/call_4mz/answer" \  -H "Authorization: Bearer $VESTRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "output": { "booking_reference": "TN-48213", "traveller_name": "Asha Rao", "flights": [{ "departure": "Delhi (DEL), 2 February 2027, 06:10", "status": "Confirmed" }] } }'
HTTP

An MCP client's own tools stay with its own agent. The tools Bash calls in your systems are answered by your code: from an SDK, the CLI or over HTTP.

# Ask as one of your travellers, naming travel-help (without it, an ask reaches your default deployment), and follow the turn as it streams.curl -N "$VESTRA_BASE_URL/v1/conversations" \  -H "Authorization: Bearer $VESTRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "user_id": "traveller-42", "deployment": "travel-help", "message": "Asha Rao here. When does my flight on TN-48213 leave?", "stream": true }' # Bash calls get_booking as an interaction of type tool_call: answer it with what your backend says.curl "$VESTRA_BASE_URL/v1/interactions/call_4mz/answer" \  -H "Authorization: Bearer $VESTRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "output": { "booking_reference": "TN-48213", "traveller_name": "Asha Rao", "flights": [{ "departure": "Delhi (DEL), 2 February 2027, 06:10", "status": "Confirmed" }] } }'

Write the tools module

--tools ./tools.mjs loads one module for vestra ask and vestra dev: a function per tool, exported under the tool's name, as named exports or as the default export's keys. Each function gets the call's arguments as one object, and the call ({ id, name, conversation, task, user }) beside them.

What happens A function that returns nothing answers null, and one that returns something JSON cannot hold answers an error. extend_rental checks the price and the return time the user said yes to against your own records, and a throw when one differs charges nothing.

tools.mjs
// Your code answers both tools in your file: a function per tool, exported under the tool's name.// vestra ask --tools ./tools.mjs and vestra dev --tools ./tools.mjs load this module.// What a function returns is the call's output, as JSON; a throw is the call's error, which Bash reads.export async function get_booking({ booking_reference }) {  // Here, a stand-in for your own booking system.  return {    booking_reference,    traveller_name: 'Asha Rao',    flights: [{ departure: 'Delhi (DEL), 2 February 2027, 06:10', seat: '14C', status: 'Confirmed' }],  };} // extend_rental runs only after your user's yes on an approval that showed charge_usd and new_due_back.// Check them against your own records before you charge: a throw charges nothing, and Bash tells the user.export async function extend_rental({ rental_id, charge_usd, new_due_back }) {  // Here, a stand-in for your own rental system.  const rental = { rental_id, daily_price_usd: 12, due_back: '2026-10-06 17:30' };  const [day, time] = rental.due_back.split(' ');  const next = new Date(`${day}T00:00:00Z`);  next.setUTCDate(next.getUTCDate() + 1);  if (charge_usd !== rental.daily_price_usd || new_due_back !== `${next.toISOString().slice(0, 10)} ${time}`) {    throw new Error(`The price or the return time of ${rental_id} has changed, so nothing was charged. Look it up again.`);  }  return { ok: true, rental_id, new_due_back, charged_usd: charge_usd };}

Or answer from your server

Once your server takes Bash's webhooks, it answers a call when none of your code is running: a routine at night, or Bash inside your app. Answer each tool from one place, because if your code and your server both answer, both run the call.

What happens A call arrives as interaction.created, type tool_call, and the handler answers it. One nobody answers expires after its timeout_s.

TypeScript
const vestra = new Vestra({  tools: {    get_booking: (input: { booking_reference: string }) =>      backend.booking(input.booking_reference),  },}); // Once: send each call to your server.// Keep the signing secret as VESTRA_WEBHOOK_SECRET.await vestra.events.to('https://api.tripnest.example/vestra', {  events: ['interaction.created'],}); // Checks each signature, and answers get_booking with your function.const handler = vestra.events.handler(async () => {});const raw = express.raw({ type: 'application/json' });app.post('/vestra', raw, handler);
Python
def get_booking(booking_reference):    return backend.booking(booking_reference)  vestra = Vestra(tools={"get_booking": get_booking}) # Once: send each call to your server.# Keep the signing secret as VESTRA_WEBHOOK_SECRET.vestra.events.to(    "https://api.tripnest.example/vestra",    events=["interaction.created"],) # Checks each signature, and answers get_booking with your function.handler = vestra.events.handler(lambda event: None)  @app.post("/vestra")def vestra_webhook():  # Flask: hand it the raw body, as it came    status, body = handler(request.get_data(), request.headers)    return body, status
CLI
# Send the calls Bash makes to your server, signed. Its signing secret is shown once.vestra events --to https://api.tripnest.example/vestra --type interaction.created
HTTP
# Send the calls Bash makes to your server. Each arrives as interaction.created, type tool_call, signed.curl "$VESTRA_BASE_URL/v1/webhooks" \  -H "Authorization: Bearer $VESTRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "url": "https://api.tripnest.example/vestra", "events": ["interaction.created"] }' # Your server answers each call by its id, with what your backend says, or an error.curl "$VESTRA_BASE_URL/v1/interactions/call_4mz/answer" \  -H "Authorization: Bearer $VESTRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "error": "No booking TN-00000 was found." }'
HTTP

Webhooks are set up once, from an SDK, the CLI or over HTTP.

# Send the calls Bash makes to your server. Each arrives as interaction.created, type tool_call, signed.curl "$VESTRA_BASE_URL/v1/webhooks" \  -H "Authorization: Bearer $VESTRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "url": "https://api.tripnest.example/vestra", "events": ["interaction.created"] }' # Your server answers each call by its id, with what your backend says, or an error.curl "$VESTRA_BASE_URL/v1/interactions/call_4mz/answer" \  -H "Authorization: Bearer $VESTRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "error": "No booking TN-00000 was found." }'

Play it in tests

A test cannot wait for your code, so its tool_results answer for it, and Bash drafts tests with them (test).

What happens Each drafted test says what get_booking answers in it. A tool with no fixture answers with an error.

Show outputHide output

The CLI prints

Added 3 tests to vestra.yaml. Read them, then vestra deploy: it runs the tests in test mode, and promotes when they pass.
TypeScript
// Drafted from your instructions, each with what your tools answer.const tests = await vestra.test.generate('vestra.yaml', { count: 3 }); // Add the ones you want to vestra.yaml's tests.for (const test of tests) console.log(test.name, test.tool_results);
Python
# Drafted from your instructions, each with what your tools answer.tests = vestra.test.generate("vestra.yaml", count=3) # Add the ones you want to vestra.yaml's tests.for test in tests:    print(test.name, test.get("tool_results"))
CLI
# Have Bash draft tests from your instructions, each with what your tools answer in it.vestra test --generate --count 3
HTTP
# Tests drafted from the deployment's instructions, each with tool_results for the tools it calls.curl "$VESTRA_BASE_URL/v1/deployments/travel-help/scenarios/generate" \  -H "Authorization: Bearer $VESTRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "count": 3 }'
HTTP

Drafting tests is done from an SDK, the CLI or over HTTP. An agent runs them with the test tool.

# Tests drafted from the deployment's instructions, each with tool_results for the tools it calls.curl "$VESTRA_BASE_URL/v1/deployments/travel-help/scenarios/generate" \  -H "Authorization: Bearer $VESTRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "count": 3 }'

Connect an MCP server

Bash calls its tools itself, and each waits for your user's yes unless your server marks it read-only (readOnlyHint) or your file's run_without_asking lists it. deny, require_approval and run_without_asking in your file apply to the version you deploy; deny and require_approval set on the server itself hold for every version, whatever the file says (deploy).

What happens A denied tool never runs, a read-only tool or one that runs without asking runs at once, and every other tool waits for your user's yes. Bash names the server by its slug, made from its URL's host (mcp-tripnest-example), never by its name, and never as one of its own servers (composio, browser-control, os-hands, vestra).

mcp-server.yaml
# In vestra.yaml: an MCP server Bash calls itself.name: travel-helptools:  - mcp: https://mcp.tripnest.example/mcp    name: tripnest-loyalty    require_approval: [redeem_points]    run_without_asking: [points_balance]    deny: [close_account]

Test the yes

The test user says no to every approval, so a test of a tool that waits checks that Bash asks first and, after the no, says the action did not happen. With approves: true it says yes to each approval for a tool of your MCP server or one of your functions that waits, as your user would in your app, and the tool's tool_results entry, keyed <slug>.<tool> for a server's tool or by the function's name, answers in its place.

What happens Bash asks, the test user approves, the fixture answers, and the judge reads what Bash told the traveller. Anything else that waits for a yes, such as a browser action on an account, is still declined.

a-test-yes.yaml
# A test whose pretend traveller says yes: Bash asks before it redeems points, the test user approves,# and the fixture answers redeem_points, so nothing runs on the loyalty server.name: travel-helptools:  - mcp: https://mcp.tripnest.example/mcp   # its slug, from the host: mcp-tripnest-example    name: tripnest-loyalty    require_approval: [redeem_points]tests:  - name: Redeems points after a yes    ask: Use 500 loyalty points for a seat upgrade on booking TN6152, and say yes when Bash asks.    expect:      - Asks for a yes before it redeems the points.      - Says 500 points were redeemed and the new seat is 3A.    never: Says the points were redeemed before the traveller said yes.    persona: Priya, a frequent flyer, says yes to the upgrade when Bash asks.    approves: true    tool_results:      mcp-tripnest-example.redeem_points: { redeemed: 500, seat: 3A }

The whole thing

Your tool, answered in a run, at your server and in your tests, in one program.

What happens Bash reaches your systems only through your code, and your tests play them without it.

TypeScript
import { Vestra } from '@vestra/sdk'; // Answer from your codeconst vestra = new Vestra({  tools: {    get_booking: (input: { booking_reference: string }) =>      backend.booking(input.booking_reference),  },});const traveller = vestra.user('traveller-42');const run = traveller.ask('Asha Rao here. When does my flight on TN-48213 leave?');console.log(await run.text()); // Or answer from your serverawait vestra.events.to('https://api.tripnest.example/vestra', {  events: ['interaction.created'],});const handler = vestra.events.handler(async () => {});const raw = express.raw({ type: 'application/json' });app.post('/vestra', raw, handler); // Play it in testsconst tests = await vestra.test.generate('vestra.yaml', { count: 3 });for (const test of tests) console.log(test.name, test.tool_results);
Python
from vestra import Vestra # Answer from your codedef get_booking(booking_reference):    return backend.booking(booking_reference)  vestra = Vestra(tools={"get_booking": get_booking})traveller = vestra.user("traveller-42")print(traveller.ask("Asha Rao here. When does my flight on TN-48213 leave?").text()) # Or answer from your servervestra.events.to(    "https://api.tripnest.example/vestra",    events=["interaction.created"],)handler = vestra.events.handler(lambda event: None)  @app.post("/vestra")def vestra_webhook():  # Flask: hand it the raw body, as it came    status, body = handler(request.get_data(), request.headers)    return body, status  # Play it in testsfor test in vestra.test.generate("vestra.yaml", count=3):    print(test.name, test.get("tool_results"))
CLI
# Answer from your code: tools.mjsvestra ask "Asha Rao here. When does my flight on TN-48213 leave?" --user traveller-42 --tools ./tools.mjs # Or answer from your servervestra events --to https://api.tripnest.example/vestra --type interaction.created # Play it in testsvestra test --generate --count 3
HTTP
# Send each call to your servercurl "$VESTRA_BASE_URL/v1/webhooks" \  -H "Authorization: Bearer $VESTRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "url": "https://api.tripnest.example/vestra", "events": ["interaction.created"] }' # Answer it by its idcurl "$VESTRA_BASE_URL/v1/interactions/call_4mz/answer" \  -H "Authorization: Bearer $VESTRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "output": { "booking_reference": "TN-48213", "traveller_name": "Asha Rao", "flights": [{ "departure": "Delhi (DEL), 2 February 2027, 06:10" }] } }' # Play it in testscurl "$VESTRA_BASE_URL/v1/deployments/travel-help/scenarios/generate" \  -H "Authorization: Bearer $VESTRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "count": 3 }'
HTTP

Your tools are answered by your code: from an SDK, the CLI or over HTTP.

# Send each call to your servercurl "$VESTRA_BASE_URL/v1/webhooks" \  -H "Authorization: Bearer $VESTRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "url": "https://api.tripnest.example/vestra", "events": ["interaction.created"] }' # Answer it by its idcurl "$VESTRA_BASE_URL/v1/interactions/call_4mz/answer" \  -H "Authorization: Bearer $VESTRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "output": { "booking_reference": "TN-48213", "traveller_name": "Asha Rao", "flights": [{ "departure": "Delhi (DEL), 2 February 2027, 06:10" }] } }' # Play it in testscurl "$VESTRA_BASE_URL/v1/deployments/travel-help/scenarios/generate" \  -H "Authorization: Bearer $VESTRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "count": 3 }'