# Tools and APIs for AI Agents | Volcano

URL: https://volcano.dev/solutions/agent-tools

> Expose your systems to an agent as callable tools on Volcano: deployed functions with the caller's verified identity, tools only signed-in users can reach, and Postgres policies deciding what each call returns.

## Give an agent hands, not the keys

Expose each capability as a function that runs with the caller's identity, so the database decides what the agent can touch.

```console
$ volcano cloud functions deploy --all
lookup-customer  deployed   private
issue-refund     deployed   private
$ volcano cloud functions update issue-refund --visibility authenticated
# signed-in users only: a bare anon key is refused
$ volcano cloud functions logs issue-refund --type runtime --follow
user 8f1c… → refund 4821 · $42.00 · allowed by policy
```

## The dangerous part is the tool, not the model

A tool is an API you have handed to something that improvises. The usual shortcut is one service account with broad rights and a promise that the prompt will keep it in line, which holds right up until someone phrases a request cleverly enough.

Make each tool a function that runs as whoever asked. The identity arrives verified on the event and the tool queries under that user's row-level policies, so the boundary is the same one your app already relies on rather than a promise about the prompt. A tool that takes longer than a call, or needs a person to sign off first, starts a durable function and returns.

## One capability, one function

*Shape*

Each tool is a small function with its own runtime, logs and visibility. Adding a capability does not mean touching a shared service that everything else depends on.

- Python, Node or Ruby per tool
- Independent deploys and independent logs
- Private by default; authenticated lets signed-in users in

```console
# Superagent project · 180s timeout
lookup-customer   python3.12   authenticated   180s
  invoke: https://b48c0d17.functions.volcano.run/
search-invoices   python3.12   authenticated   180s
  invoke: https://2f6e81a9.functions.volcano.run/
issue-refund      nodejs24.x   authenticated   180s
  invoke: https://7a1f92b4.functions.volcano.run/
close-account     python3.12   private         180s
  invoke: https://c05b7e32.functions.volcano.run/
# close-account answers only your service key; to anyone else it is a 404
```

## The caller comes with the call

*Identity*

Volcano verifies the session and puts the user on the event before your code runs. The tool never parses a token, and it never has to decide who it is acting for.

- user\_id, email, role and project\_id on the event
- Same identity the database sees as auth.uid()
- No shared service account to over-scope

Image: Deployed tools, each private until you let signed-in users in.

## Refuse in the database, not the prompt

*Limits*

A tool that connects as the caller inherits their policies, so an unauthorized action fails at the query rather than at a guardrail you wrote. Turn the refusal into a 403 and let the agent explain it.

- Policies are the same rules your app uses
- Denied writes surface as errors, not silent success
- Nothing to keep in sync with the prompt

```console
# same tool, same arguments, different user
ada  (owns order 4821)   → 202 refund queued
cleo (does not own it)   → 403 not permitted
the policy decided, not the prompt
```

## How it works

1. **Pick the capabilities**: One function per verb the agent needs. Keep each one narrow enough to describe in a sentence.
2. **Read the identity**: Take the user off the event and connect as them, so the database applies their policies.
3. **Guard the sharp ones**: Set the tools the agent calls for a user to authenticated, so a bare anon key is refused, and leave the ones only your backend runs private, the default. The policy decides the rest.
4. **Describe the tools**: A JSON schema per tool, generated from the same list you deploy, so the two cannot drift.

## A tool, its guardrail, its schema, its deploy

### The tool

```javascript
const { Client } = require('pg');
const { databaseConnectionString } = require('@volcano.dev/sdk');

exports.handler = async (event) => {
  // authenticated admits anonymous sign-ins too, and a refund needs a real account.
  const auth = event.__volcano_auth;
  if (!auth || auth.role === 'anonymous') {
    return { statusCode: 401, body: JSON.stringify({ error: 'sign in required' }) };
  }

  const { orderId, amountCents } = event;

  if (!Number.isInteger(amountCents) || amountCents <= 0) {
    return { statusCode: 400, body: JSON.stringify({ error: 'amountCents must be a positive integer' }) };
  }

  // Read as the caller: their policy decides whether this order is theirs.
  const asCaller = new Client({
    connectionString: databaseConnectionString(process.env.DATABASE_URL, {
      userId: auth.user_id,
    }),
  });
  await asCaller.connect();
  let owned;
  try {
    ({ rowCount: owned } = await asCaller.query('select 1 from orders where id = $1', [orderId]));
  } finally {
    await asCaller.end();
  }
  if (owned === 0) {
    return { statusCode: 403, body: JSON.stringify({ error: 'not refundable by this user' }) };
  }

  // Write with full access. Customers can only read orders, so the refund
  // columns change here, behind this check, and nowhere else.
  const admin = new Client({ connectionString: process.env.DATABASE_URL });
  await admin.connect();
  try {
    const { rowCount } = await admin.query(
      `update orders set refunded_cents = refunded_cents + $1, refunded_at = now()
         where id = $2 and refunded_cents + $1 <= total_cents`,
      [amountCents, orderId],
    );
    if (rowCount === 0) {
      return { statusCode: 409, body: JSON.stringify({ error: 'refund exceeds the order total' }) };
    }
  } finally {
    await admin.end();
  }

  return { statusCode: 202, body: JSON.stringify({ refunded: amountCents }) };
};
```

### Ownership

```sql
alter table orders enable row level security;

-- A customer sees their own orders. No customer policy allows writes, so a
-- refund can only happen through the tool.
drop policy if exists own_orders on orders;
create policy own_orders on orders
  for select to authenticated
  using (customer_id = auth.uid());

-- Support staff see everything, through a role on their own table rather
-- than a second service account.
drop policy if exists support_sees_all on orders;
create policy support_sees_all on orders
  for select to authenticated
  using (exists (
    select 1 from staff s
    where s.user_id = auth.uid() and s.role in ('support', 'admin')
  ));

drop policy if exists support_refunds on orders;
create policy support_refunds on orders
  for update to authenticated
  using (exists (
    select 1 from staff s
    where s.user_id = auth.uid() and s.role in ('support', 'admin')
  ));
```

### Schema

```typescript
export const tools = [
  {
    name: 'search_invoices',
    function: 'search-invoices',
    description: "Search the signed-in customer's invoices by free text.",
    parameters: {
      type: 'object',
      properties: {
        query: { type: 'string', description: 'Text to match against the invoice description' },
        limit: { type: 'integer', minimum: 1, maximum: 20, default: 5 },
      },
      required: ['query'],
    },
  },
  {
    name: 'issue_refund',
    function: 'issue-refund',
    description: 'Refund part or all of an order the customer owns.',
    parameters: {
      type: 'object',
      properties: {
        orderId: { type: 'string' },
        amountCents: { type: 'integer', minimum: 1 },
      },
      required: ['orderId', 'amountCents'],
    },
  },
] as const;

// The model gets the descriptions; your loop gets the function names.
export const toolSchema = tools.map(({ name, description, parameters }) => ({
  name,
  description,
  parameters,
}));
```

### Ship it

```bash
volcano use support-agent

volcano cloud functions deploy --all

# Functions deploy private: only service keys and schedulers reach them.
# Let signed-in users call the tools; close-account stays private.
volcano cloud functions update lookup-customer --visibility authenticated
volcano cloud functions update search-invoices --visibility authenticated
volcano cloud functions update issue-refund --visibility authenticated

# Provider keys and internal URLs live in project variables
volcano cloud variables deploy

# Verify what a real call did
volcano cloud functions logs issue-refund --type runtime --follow
```

## What this removes

- **0 service accounts to scope**: Tools run as the asking user, so there is no broad key to review every quarter.
- **One place for the access rule**: The policy that guards your app already guards every tool you add later.
- **Per tool logs and visibility**: Each capability deploys, logs and fails on its own instead of inside one service.

## Outcomes

- **Bounded by identity**: A convincing prompt still cannot exceed the caller's permissions.
- **Safe to extend**: New tools inherit the rules instead of restating them.
- **Traceable**: Every call is attributable to a user in the runtime logs.

## Built on Volcano

- [The tools themselves](https://volcano.dev/features/edge-functions): Functions in Node, Python or Ruby, deployed one at a time, each with its own logs and visibility.
- [Who is calling](https://volcano.dev/features/authentication): Verified sessions delivered to the function and understood by the database as auth.uid().
- [What they may touch](https://volcano.dev/features/postgres): Row-level policies that decide what a tool returns, written once next to the data.
- [Tools that outlast a call](https://volcano.dev/features/durable-functions): Long or irreversible actions run as durable functions that retry each step and wait until a person approves.

## Frequently asked questions

### How does a tool know who is calling it?

Volcano verifies the session and attaches the user to the event as \_\_volcano\_auth, with user\_id, email, role, project\_id and the caller's access token. Your code reads it directly and never parses a token.

### What stops the agent calling a tool it should not?

The policy, not the prompt. A tool that connects as the caller is bounded by that user's row-level policies whatever the model decides to try, so an unauthorized action fails at the query. A tool's visibility adds a second gate before it runs: an authenticated tool refuses a bare anon key, and a private one answers only your service keys.

### What do the visibility levels do?

They decide which callers a tool runs for. Private, the default, admits only your service keys and schedulers; to anyone else the tool does not exist and answers 404. Authenticated also admits your project's signed-in users, including anonymous sign-ins, so check the role when a tool needs a real account. Public also admits anon keys holding functions.invoke. A caller who gets in can still do only what their row-level policies allow, which is why the policy remains the real boundary for anything destructive.

### How long can a tool run?

Up to 30 seconds per call on Hobby or 180 seconds on Superagent. For work that takes longer, have the tool start a durable function and return straight away. The function writes its result to a table the user's policies cover, so the agent reads it back with another tool instead of holding the call open.

### Can a person approve a tool call before it runs?

Yes. Have the tool start a durable function and return. The function calls ctx.waitForApproval before the irreversible step, the request appears under Approvals in the dashboard with the details you attached, and the run carries on with the decision once someone approves or denies it. A person decides from the dashboard, the CLI or the API with their own token. A project access token is refused, so an agent holding one cannot approve its own call.

### Can a tool call another tool?

Yes, over HTTP. The caller's access token is on the event as \_\_volcano\_auth.access\_token, so forward it and the second tool runs as the same user rather than escalating partway through the chain.

## Get started

Ready to expose your first tool? Create a free account at https://volcano.dev/signup, or have your coding agent follow the setup playbook at https://volcano.dev/setup.md.
