On this page

#Long-running tools

A long-running tool is one whose work takes longer than a reply can wait: recommendations that write a text for each match, a report or an export, a quote that waits on a supplier, or a job that drives a browser for minutes. A Vatio tool has 8 seconds to answer. An async tool answers within those 8 seconds that it accepted the job, and your backend posts the result when it has it. Meanwhile the agent tells the person it is working on it, keeps talking with them, and gives them the result in the same conversation: in the web widget, in your iOS, Android or Flutter app, on WhatsApp or on Instagram.

Visitor on any channel Vatio and your agent one conversation Your backend the async tool's endpoint "Find me a therapist for anxiety" POST /vatio/match + job id, callback URL 202 Accepted "I'm on it, I'll write here" JOB RUNNING seen in every turn POST callback {progress} "How is it going?" "Still on it: 12 to go" POST callback {result, data} 200 Written as the tool's answer a turn of its own gives it "Here are two therapists near you…" cancel: called off or timed out
The life of an async tool's job: accepted at once, known to the agent while it runs, delivered as the tool's answer when your backend posts it.

#When a tool should be async

  • It is often slower than a few seconds. A reply that waits on a slow tool is a person staring at a typing indicator, and at 8 seconds the call fails. Make it async rather than racing the limit.
  • The work is a job, not a lookup. Generating text per result, crawling, running a model, filling in a form on someone else's site: anything you would put on a queue in your own app.
  • Not when it can be made fast. Reading a row, checking stock or booking a slot should answer in the call. An async tool costs the person a second message to read.

Every other part of a tool stays the same: its parameters, access, headers and respond: mapping (Tools).

#1. Declare the tool

Add async: to the tool's file. async: true is enough; the full form sets how long the job may run and what Vatio sends to call it off.

tools/match_therapists.yml
description: Finds therapists who fit the patient's reason for consulting and area, with a short text on why each one fits.
when_to_use: The patient told you what is going on and where they live.
parameters:
type: object
properties:
reason: { type: string, description: "In the patient's words" }
area: { type: string }
required: [reason, area]
async:
timeout: 10m
cancel:
method: DELETE
path: /vatio/jobs/$job.id
request:
method: POST
base_url: "$env.CLINIC_API"
path: /vatio/match
headers:
Authorization: "Bearer $env.CLINIC_API_KEY"
body:
reason:
area:
respond:
data:
therapists: "$.data.therapists"

vatio diff checks it and vatio push deploys it to preview. The agent's prompt says the tool runs in the background, so the agent can tell the person it will take a moment. The reference for every key is A tool that takes longer: async.

#2. Accept the job

Your endpoint gets the call as any tool does, plus the job's id and the URL to post its result to, in the headers X-Vatio-Job-Id and X-Vatio-Callback-Url. Put the work on your queue and answer 202 right away. The message you return reaches the agent, so it can say how long the job usually takes.

// Express
app.post("/vatio/match", requireToolKey, async (req, res) => {
const job = {
id: req.get("X-Vatio-Job-Id"),
callbackUrl: req.get("X-Vatio-Callback-Url"),
};
await queue.add("match-therapists", { job, reason: req.body.reason, area: req.body.area }, { jobId: job.id });
res.status(202).json({ message: "Finding matches; this usually takes about two minutes." });
});

requireToolKey is the check your tools already have: it verifies the Authorization header the tool sends. The callback URL needs no key, yours or Vatio's: it is signed and names this one job. Treat it as a secret and keep it out of your logs.

If the answer is sometimes ready at once (a cache hit, say), answer 200 with the result instead, as a normal tool would. Then there is no job.

In Rails, the Ruby gem reads the job and answers 202 for you, and its run posts your worker's result, or reports the job failed if the worker raises:

class Vatio::MatchesController < ApplicationController
include Vatio::Server::AsyncTool
def create
MatchTherapistsJob.perform_later(vatio_job, reason: params[:reason], area: params[:area])
accept_vatio_job(message: "Finding matches; this usually takes about two minutes.")
end
end

#3. Post progress and the result

Your worker posts to the callback URL. The final body is the one the endpoint would have answered if it had been fast: respond: maps it the same way. "result": "error" with a message ends the job as failed, and the agent explains it to the person.

async function matchTherapists({ job, reason, area }) {
await callback(job.callbackUrl, { progress: "Reviewing 12 therapists in your area", notify: true });
const therapists = await findMatches(reason, area); // the slow part
await callback(job.callbackUrl, {
result: "ok",
message: `${therapists.length} therapists found`,
data: { therapists },
});
}
// At least once: a repeated result is answered 200 again. 409 and 404 mean
// the job is over (timed out or called off), so stop.
async function callback(url, body) {
for (let attempt = 1; attempt <= 5; attempt++) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).catch(() => null);
if (res && res.status < 500 && res.status !== 429) return res;
await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
}
}

Progress is optional. Without notify, it is kept for when the person asks how it is going; with "notify": true, the agent tells them right away. Use it for jobs that take minutes, not for every step.

#4. Let the person change their mind

While a job runs, the agent can call it off when the person no longer wants it. Vatio then sends the tool's cancel request. It also sends it when the job runs past its timeout. Stop the work if you can; either way, the result will not be delivered.

app.delete("/vatio/jobs/:id", requireToolKey, async (req, res) => {
await queue.remove(req.params.id);
res.json({ result: "ok", message: "Cancelled" });
});

#What the person sees

In the web widget, with a person who writes while the job runs:

Patient: I've been having a lot of anxiety lately, I live in Ñuñoa

Agent: I'm looking for therapists who fit what you're going through, in Ñuñoa. It takes a couple of minutes, and I'll write here as soon as I have them.

Patient: how's it going?

Agent: Still on it: I'm reviewing 12 therapists in your area.

Agent: I have two for you. Ana Rojas treats anxiety with cognitive behavioural therapy and sees patients in Ñuñoa on Tuesdays and Thursdays. Pedro Soto works with mindfulness and sees patients online or in Providencia.

Patient: does the second one do online sessions?

Agent: Yes, Pedro sees patients online as well as in Providencia.

The last answer comes from the result itself: once delivered, the result is in the conversation as the tool's answer, so later questions about it need no new call. Asked how it was going, the agent did not call the tool again, because every turn knows which jobs are running.

The result reaches the person wherever the conversation is:

WhereHow the result arrives
Web widget, iOS, Android and Flutter appsAs the agent's next message: live if the chat is open, in the history when it opens again
WhatsApp and InstagramAs a message, while the 24-hour window is open
WhatsApp, window closedThe whatsapp_template you name brings them back, and the agent gives the result when they answer
A person on your team took overIt waits, and the agent gives it on its next turn

#Test it

Push to preview and talk to the agent from your terminal:

Terminal window
vatio push
vatio chat "I've been having a lot of anxiety lately, I live in Ñuñoa"
vatio chat show

vatio chat show prints the call, the job it started and, once your backend posts, the result and the reply that gave it. To try your callback by hand, copy X-Vatio-Callback-Url from your backend's request and post to it with curl. A test replay never waits for a callback: the agent is told the job started, as it was in the conversation being replayed.

#Frequently asked questions

#How long can a background job take?

Up to 8 hours, set with async.timeout (10 minutes if you leave it out). That is as long as a WhatsApp or Instagram conversation lasts without a message, so the result always lands in the conversation the person is still in. When it passes without a result, the job ends, your cancel request is sent and the agent tells the person it could not be done this time.

#What if the person writes while the job runs?

The agent answers them as usual. It knows the job is running, so it does not start it again, and it can tell them how far along it is from your last progress. If the result arrives while their message is waiting for an answer, that reply includes the result.

#Can a conversation have several jobs at once?

Yes, up to 5. The same tool with the same arguments is one job: asking again while it runs does not start a second one.

#What happens if my backend posts the result twice?

Nothing new: the repeat gets 200 with "duplicate": true, and the person hears the result once. A post after the job ended (it timed out or was called off) gets 409, so your retries can stop.

#Does the callback need an API key?

No. The URL carries a signed token that names one job and stops working a day after the job's timeout. Anyone who has it can post that job's result, so keep it out of logs.

#Can the agent use the result later in the conversation?

Yes. The result is written into the conversation as the tool's answer, so the agent reads it on every later turn, and safeguards treat amounts and links in it like those from any tool.

  • Tools: Every key of a tool's file, async included, and what your backend should answer.
  • The AI agent runtime: What happens between a message and the reply, including the turns nobody wrote.
  • Best practices: Instructions, tools, knowledge, handoff and tests: what works for an agent real users talk to.

Last updated:

Latest CLI version: 0.114.0 · Changelog · The whole contract as Markdown: /docs/docs.md · or vatio docs