# Build and Run AI Agents | Volcano

URL: https://volcano.dev/solutions/ai-agents

> Run AI agents on Volcano: the agent loop as a durable function that checkpoints every model and tool call and waits for human approval, tools as functions in Node, Python or Ruby, memory in Postgres with row-level security, and every call carrying the user's identity.

## Agents that run for hours and still finish

Run the agent loop as a durable function that checkpoints every call and waits for a person's approval, with tools as functions, memory in Postgres, and the user's permissions on every query.

```console
$ volcano cloud durable deploy --all
agent-run        python3.14   durable
$ volcano cloud functions deploy --all
search-orders    python3.12   deployed
issue-refund     python3.12   deployed
$ volcano cloud databases migration up --all -d app
001_threads.sql  002_policies.sql  ok
✓ runs checkpointed · tools live · policies on every table
# a failed tool or a crash resumes the run; it never starts over
```

## The model is the easy part. Finishing the run is not.

A prototype agent is a loop inside one request. A real one runs for minutes or hours, calls tools that time out, waits for a person to approve a refund, and crashes halfway through a plan. Restart it from the top and you pay for every model call again, and repeat every tool call that already went through.

On Volcano the loop is a durable function. Every model call and tool call is a checkpointed step, so a failed or interrupted step can retry, completed steps replay without rerunning, and a wait for approval holds nothing running. Tools deploy as functions, memory is ordinary Postgres under row-level security, and whatever the agent does for a user can only reach that user's rows.

## A run that survives a failed tool call

*Runs*

Write the loop as a durable function and each model call and tool call is a step. A tool that times out is retried on its own, and completed model calls replay from their checkpoints after a crash instead of being paid for again. Use provider idempotency keys when an interrupted attempt must not repeat a call.

- Every model and tool call checkpointed
- Hours of work, or days of waiting, in one run
- A failed tool retries without rerunning the plan

```console
step  model-1        plan: find the order, then refund
step  tool-1         search-orders → 1 match
step  model-2        wants issue_refund · $42.00
wait  approve-2      pending → approved by support
step  refund-2       issue_refund → refunded
✓ succeeded · completed model steps replayed from checkpoints
```

## A person signs off before it acts

*Human in the loop*

When the model reaches for something it cannot take back, such as a refund, the run calls ctx.wait\_for\_approval with what it wants to do. It suspends until someone on the project approves or denies the request under Approvals, then resumes with their decision and comment. A project access token is refused, so an agent holding one cannot approve its own request.

- Approve or deny from the dashboard, the CLI or an SDK
- Nothing runs, and no compute is charged, while it waits
- A timeout turns silence into an outcome the agent handles

Image: A refund the agent wants to issue, waiting for approval with the order details and the approve and deny buttons.

## A tool is a function that runs as the caller

*Tools*

Write each tool in Python, Node or Ruby and deploy it with one command. Every invocation carries a verified identity on the event, so a tool queries as the person who asked rather than with a key that can read everything, and a prompt injection cannot widen what the database will return.

- event.\_\_volcano\_auth holds the verified user
- Their access token rides along, so a tool can call a tool
- Up to 30 seconds per tool call on Hobby or 180 on Superagent

Image: Agent tools deployed as functions, with their runtime and visibility.

## How it works

1. **Model the threads**: Threads, messages and tool calls as tables, with a user\_id on each and row-level security switched on.
2. **A function per tool**: One function per capability. Read the identity off the event and let the database decide what it returns.
3. **Run the loop**: A durable function that calls your model, runs each tool call as a step, and waits for a person's approval before anything irreversible.
4. **Give it a face**: A Next.js app on Volcano, streaming progress over a realtime channel while the loop works.

## Memory, a tool, the run, and the deploy

### Memory

```sql
create table if not exists threads (
  id         uuid primary key default gen_random_uuid(),
  user_id    uuid not null,
  title      text,
  created_at timestamptz not null default now()
);

create table if not exists messages (
  id         uuid primary key default gen_random_uuid(),
  thread_id  uuid not null references threads(id) on delete cascade,
  user_id    uuid not null,
  role       text not null check (role in ('user', 'assistant', 'tool')),
  content    text not null,
  tool_name  text,
  created_at timestamptz not null default now()
);

create index if not exists messages_thread_idx on messages (thread_id, created_at);

alter table threads  enable row level security;
alter table messages enable row level security;

-- The agent runs as the user. These policies are the whole security model.
drop policy if exists own_threads on threads;
create policy own_threads on threads
  for all to authenticated
  using (user_id = auth.uid()) with check (user_id = auth.uid());

drop policy if exists own_messages on messages;
create policy own_messages on messages
  for all to authenticated
  using (user_id = auth.uid()) with check (user_id = auth.uid());
```

### A tool

```python
import json
import os
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse

import psycopg


def handler(event, context):
    auth = event.get("__volcano_auth")
    if not auth:
        return {"statusCode": 401, "body": json.dumps({"error": "sign in required"})}

    query = event.get("query", "")

    # Connect as the caller so row-level security applies to this tool the same
    # way it applies to the app. A prompt cannot talk its way past this.
    parts = urlparse(os.environ["DATABASE_URL"])
    params = dict(parse_qsl(parts.query))
    params["application_name"] = f"volcano_user_access:{auth['user_id']}"
    dsn = urlunparse(parts._replace(query=urlencode(params)))

    with psycopg.connect(dsn) as db, db.cursor() as cur:
        cur.execute(
            """
            select id, status, total_cents, placed_at
            from orders
            where description ilike %s
            order by placed_at desc
            limit 5
            """,
            (f"%{query}%",),
        )
        rows = cur.fetchall()

    # Only this user's orders can come back, whatever the model asked for.
    return {
        "statusCode": 200,
        "body": json.dumps([
            {"id": str(r[0]), "status": r[1], "total_cents": r[2]} for r in rows
        ]),
    }
```

### The run

```python
import json
import os
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse

import psycopg
from volcano_sdk.durable_authoring import durable

# The tool code the deployed tool functions use, shared from
# volcano/functions/_shared so the run calls it in-process.
from _shared.tools import (
    append_message,
    call_model,
    issue_refund,
    load_thread,
    run_tool,
)

MAX_TURNS = 12


@durable
def handler(event, ctx):
    # Started from the app with volcano.durable.start("agent-run", ...) under the
    # user's session. Volcano puts that verified user on the input and strips
    # any __volcano_auth a client tries to send itself.
    auth = event.get("__volcano_auth")
    if not auth:
        return {"error": "sign in required"}

    def as_user(fn):
        # Every query connects as that user, so row-level security bounds the
        # agent however long the run takes.
        with psycopg.connect(dsn_for(auth["user_id"])) as db:
            return fn(db)

    thread_id = event["thread_id"]
    messages = ctx.step("load-thread", lambda s: as_user(lambda db: load_thread(db, thread_id)))

    for turn in range(1, MAX_TURNS + 1):
        # Once checkpointed, a later crash replays this result without another call.
        reply = ctx.step(f"model-{turn}", lambda s: call_model(messages))
        ctx.step(f"save-{turn}", lambda s: as_user(lambda db: append_message(db, thread_id, reply)))

        call = reply.get("tool_call")
        if not call:
            return {"reply": reply["content"]}

        if call["name"] == "issue_refund":
            # Irreversible, so a person signs off first. The run suspends, with
            # nothing running, until someone decides under Approvals or 3 days pass.
            args = call["arguments"]
            decision = ctx.wait_for_approval(
                f"approve-{turn}",
                title=f"Refund ${args['amount']} on order {args['order_id']}?",
                description=f"The support agent wants to call {call['name']}.",
                details=args,
                timeout="3d",
            )
            if not decision.approved:
                return {"reply": "The refund was not approved.", "comment": decision.comment}

            result = ctx.step(
                f"refund-{turn}",
                lambda s: as_user(lambda db: issue_refund(db, args)),
                at_most_once=True,
                retry=False,
            )
        else:
            result = ctx.step(f"tool-{turn}", lambda s: as_user(lambda db: run_tool(db, call)))

        messages = [*messages, reply, {"role": "tool", "content": json.dumps(result)}]

    return {"reply": "I could not finish that."}


def dsn_for(user_id):
    parts = urlparse(os.environ["DATABASE_URL"])
    params = dict(parse_qsl(parts.query))
    params["application_name"] = f"volcano_user_access:{user_id}"
    return urlunparse(parts._replace(query=urlencode(params)))
```

### Ship it

```bash
volcano login
volcano use my-agent

# Schema first, then the tools and the run
volcano cloud databases migration up --all -d app
volcano cloud functions deploy --all
# agent-run is kind: durable in the manifest. The app starts it with the
# user's session, so it admits signed-in users.
volcano cloud durable deploy -f agent-run --visibility authenticated

# Your model provider key lives here, not in the repo
volcano cloud variables deploy

# Functions start private: only service keys and schedulers reach them.
# Let signed-in users call the tools; the policy bounds what each can do.
volcano cloud functions update search-orders --visibility authenticated
volcano cloud functions update issue-refund --visibility authenticated

# Watch the runs in flight, and what they log
volcano cloud durable executions list agent-run --status running
volcano cloud durable logs agent-run --type runtime --follow
```

## What you skip

- **One identity from client to row**: The user's token reaches the tool and the policy, so no tool needs its own auth logic.
- **3 runtimes for tools**: Python, Node and Ruby, so a tool can live wherever the library you need already is.
- **366 days for one agent run**: Checkpointed per step, so a run can wait for approval or retry a tool without starting over.

## Outcomes

- **Scoped by default**: A tool can only reach what the asking user could reach.
- **Runs that finish**: A failed tool or a crash resumes the run instead of restarting it.
- **Nothing to operate**: No queue, no worker fleet, nothing running between turns.

## Built on Volcano

- [The tools](https://volcano.dev/features/edge-functions): Functions in Python, Node or Ruby, each one receiving the caller's verified identity.
- [Runs that outlast a request](https://volcano.dev/features/durable-functions): Durable functions that checkpoint every model and tool call, retry what fails and wait for approval.
- [Memory and retrieval](https://volcano.dev/features/postgres): Threads and messages as tables, embeddings in a pgvector column, and row-level security over both.
- [Who the agent acts for](https://volcano.dev/features/authentication): Sessions that reach the database as auth.uid(), so the agent inherits a real user instead of an admin key.

## Frequently asked questions

### Does Volcano run the model?

No. You call whichever provider you use from inside a function, with the key stored as a project variable. Volcano runs the tools, the loop and the memory around it.

### How do I stop a prompt injection reading another customer's data?

Connect as the caller instead of as an admin. The tool inherits that user's row-level policies, so a convincing prompt still cannot make Postgres return rows the user was never allowed to see.

### Why run the agent loop as a durable function?

Because agent runs fail in the middle. Each model call and tool call becomes a checkpointed step, so one run can take hours, wait days for an approval, and replay completed steps after a failure. An interrupted attempt can retry, so use provider idempotency keys for calls that must not repeat. An execution can run for up to 366 days, and each step gets up to 300 seconds on Hobby or 900 seconds on Superagent.

### How does a person approve what the agent is about to do?

Call ctx.wait\_for\_approval before the step, with a title and the details someone needs to decide, such as the order and the amount. The run suspends without running anything, the request appears under Approvals in the dashboard, and a person approves or denies it there, with volcano cloud durable approvals, or through the API. The run resumes with the decision and any comment. A project access token is refused, so an agent holding one still cannot approve its own request, and a timeout turns an unanswered request into an expired one.

### How do several agents avoid working on the same task?

Give each task a lock. An agent that calls volcano.locks.withLock('ticket:4821', { ttl: 60 }, work) holds the task while it works, every other agent gets acquired: false and moves on, and a task whose agent crashed is free again when its lease runs out.

### How do I show the user what the agent is doing?

Broadcast each step on a realtime channel while the loop runs. The page updates as tools are called, and nobody has to poll a status endpoint.

## Get started

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