Agent tools

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.

tools
volcano cloud functions deploy --alllookup-customer  deployed   privateissue-refund     deployed   privatevolcano cloud functions update issue-refund --visibility authenticated# signed-in users only: a bare anon key is refusedvolcano cloud functions logs issue-refund --type runtime --followuser 8f1c… → refund 4821 · $42.00 · allowed by policy
Overview

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.

Shape

One capability, one function

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
volcano cloud functions list
# Superagent project · 180s timeoutlookup-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
Identity

The caller comes with the call

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
volcano.dev/dashboard/edge-function
Deployed tools, each private until you let signed-in users in
Limits

Refuse in the database, not the prompt

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
issue-refund, two callers
# same tool, same arguments, different userada  (owns order 4821)   → 202 refund queuedcleo (does not own it)   → 403 not permittedthe policy decided, not the prompt
Path to production

How it works

  1. 01

    Pick the capabilities

    One function per verb the agent needs. Keep each one narrow enough to describe in a sentence.

  2. 02

    Read the identity

    Take the user off the event and connect as them, so the database applies their policies.

  3. 03

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

    Describe the tools

    A JSON schema per tool, generated from the same list you deploy, so the two cannot drift.

Code

A tool, its guardrail, its schema, its deploy

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 }) }; };
Return

What this removes

0service accounts to scope

Tools run as the asking user, so there is no broad key to review every quarter.

One placefor the access rule

The policy that guards your app already guards every tool you add later.

Per toollogs and visibility

Each capability deploys, logs and fails on its own instead of inside one service.

What you get

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.

Platform

Built on Volcano

Functions and agents
  • The tools themselves

    Functions in Node, Python or Ruby, deployed one at a time, each with its own logs and visibility.

Authentication
  • Who is calling

    Verified sessions delivered to the function and understood by the database as auth.uid().

Databases and vector
  • What they may touch

    Row-level policies that decide what a tool returns, written once next to the data.

Durable functions
  • Tools that outlast a call

    Long or irreversible actions run as durable functions that retry each step and wait until a person approves.

Frequently asked questions

Read the docs
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.

Ready to expose your first tool?

Build, deploy, and scale on Volcano's global platform — free to start, with no infrastructure to manage.

Explore more solutions