# Give Bash your tools

> Let Bash reach your own systems: tools your code answers, fixtures that play them in tests, and MCP servers.

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](https://vestra.ai/developers/guides/travel-help-desk.md).

About 10 minutes. It uses [`deploy`](https://vestra.ai/developers/deploy.md), [`ask`](https://vestra.ai/developers/ask.md), [`answer`](https://vestra.ai/developers/answer.md), [`events`](https://vestra.ai/developers/events.md), [`test`](https://vestra.ai/developers/test.md).

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

`tools.yaml`:

```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-help
tools:
  - 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 }
```

**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'}`).

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

**TypeScript**

```ts
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**

```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**

```bash
# 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**

```bash
# 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" }] } }'
```

**MCP**

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.

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

The CLI prints:

```text
  your tool get_booking was called: your module answers it
```

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

`tools.mjs`:

```ts
// 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 };
}
```

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

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

**TypeScript**

```ts
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**

```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**

```bash
# 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**

```bash
# 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." }'
```

**MCP**

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

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

## 5. 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`](https://vestra.ai/developers/test.md)).

**TypeScript**

```ts
// 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**

```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**

```bash
# Have Bash draft tests from your instructions, each with what your tools answer in it.
vestra test --generate --count 3
```

**HTTP**

```bash
# 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 }'
```

**MCP**

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

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

The CLI prints:

```text
Added 3 tests to vestra.yaml. Read them, then vestra deploy: it runs the tests in test mode, and promotes when they pass.
```

## 6. 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`](https://vestra.ai/developers/deploy.md)).

`mcp-server.yaml`:

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

**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`).

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

`a-test-yes.yaml`:

```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-help
tools:
  - 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 }
```

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

## 8. The whole thing

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

**TypeScript**

```ts
import { Vestra } from '@vestra/sdk';

// Answer from your code
const 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 server
await 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 tests
const tests = await vestra.test.generate('vestra.yaml', { count: 3 });
for (const test of tests) console.log(test.name, test.tool_results);
```

**Python**

```python
from vestra import Vestra

# Answer from your code
def 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 server
vestra.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 tests
for test in vestra.test.generate("vestra.yaml", count=3):
    print(test.name, test.get("tool_results"))
```

**CLI**

```bash
# Answer from your code: tools.mjs
vestra ask "Asha Rao here. When does my flight on TN-48213 leave?" --user traveller-42 --tools ./tools.mjs

# Or answer from your server
vestra events --to https://api.tripnest.example/vestra --type interaction.created

# Play it in tests
vestra test --generate --count 3
```

**HTTP**

```bash
# Send each call to your server
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"] }'

# Answer it by its id
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" }] } }'

# Play it in tests
curl "$VESTRA_BASE_URL/v1/deployments/travel-help/scenarios/generate" \
  -H "Authorization: Bearer $VESTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "count": 3 }'
```

**MCP**

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

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

Next: [Let your user say yes](https://vestra.ai/developers/guides/approvals.md)
