# Vatio documentation

Vatio is a runtime for customer-facing AI agents. This file is the whole site (https://vatio.ai/docs) as one document. Every page also exists on its own, at its path plus `.md`.

## Documentation

### Introduction

`https://vatio.ai/docs/`

Vatio is a **Claude Code plugin backed by a hosted conversation runtime**.
If you already have an app and users, describe what its agent should do.
Claude Code builds it from your project; Vatio runs the conversations with
visitor identity, access controls, human handoff and preview/live deployments.

You do not need to write the agent configuration yourself. You decide what it
may do, try it before publication and approve changes.

#### Get started

1. [Create a Vatio account](https://vatio.ai/session/new). Email sign-in creates
   an account if you do not have one; Google and GitHub are available too.
   Signing in takes you to [the inbox](https://inbox.vatio.ai), where the
   agent is tried, connected and supervised.
2. Open your app's project in Claude Code and [install the plugin](https://vatio.ai/docs/integrations/claude-code).
3. Describe your agent in one prompt. Claude follows `vatio-init`, reads your
   app, asks what the code cannot answer and gives you a preview to try.
4. Approve publication when you are ready for users.

```text
Use vatio-init to build a support agent for this app. Connect it to our
existing users and features, define its permissions and human handoff,
and give me a preview to try before publishing.
```

- [Install the Claude Code plugin](https://vatio.ai/docs/integrations/claude-code) — Build with vatio-init. Improve with vatio-improve.
- [Supervise conversations](https://vatio.ai/docs/channels/inbox) — Read replies, take over and report what should have happened.
- [Your daily improvement routine](https://vatio.ai/docs/improve) — Run vatio-improve, review the tests and approve the update.

#### Improve from real conversations

Report a wrong reply in the inbox and say what should have happened. Then run
`/vatio:vatio-improve` in Claude Code. It fixes the cause in your project and
Vatio replays the reported cases against preview. Review the results before
publishing. A case that passes on live and fails on preview blocks publication
unless you explicitly override the check.

The daily email brings the command when new reports arrive. This is an owner-led
routine; Vatio does not silently rewrite or publish your agent. Tests cover
reported cases, not every possible conversation.

#### Where to do what

- **Claude Code:** create the agent, change its behavior and connections, run
  evaluations, and publish with your approval.
- **Inbox** ([inbox.vatio.ai](https://inbox.vatio.ai)): follow conversations,
  reply as a person, report mistakes and edit the business knowledge. Whoever
  builds the agent also finds there **Test** (try preview, publish),
  **Channels** (widget, WhatsApp, Instagram, Supabase), **Tools**,
  **Outreach**, and, for the owner, **Team** and **Security**. Supervisors and
  viewers get the conversations, contacts, improvement, analytics, knowledge
  and business sections; [Teams](https://vatio.ai/docs/team) has who sees what.

#### Technical reference

The pages below are the contract Claude Code uses: configuration, tools,
knowledge, authentication, channels and APIs. If you want to operate the CLI
by hand, follow the [manual quickstart](https://vatio.ai/docs/quickstart).
Commands written as `vatio` assume a [global CLI install](https://vatio.ai/docs/cli/); you can
replace the prefix with `npx @vatio-ai/cli@latest`.

#### Build

- [Manifest](https://vatio.ai/docs/manifest) — The workspace contract: one vatio.yml, its root keys and what each one owns.
- [Tools](https://vatio.ai/docs/tools) — Give the agent your API. One HTTP request, described in YAML.
- [Knowledge](https://vatio.ai/docs/knowledge) — Entries you write, from the CLI or the inbox, versioned and published with the agent.
- [Authentication](https://vatio.ai/docs/authentication/) — You sign a JWT saying who the visitor is; Vatio verifies it and never mints one.

#### Connect

- [Web widget](https://vatio.ai/docs/channels/widget) — One script tag, themed from the manifest or per page.
- [WhatsApp](https://vatio.ai/docs/channels/whatsapp) — Test on the shared number in a minute; connect your own for live traffic.
- [Instagram](https://vatio.ai/docs/channels/instagram) — Answer DMs with the same agent, on a professional account.
- [Browser SDK](https://vatio.ai/docs/sdk/) — Build your own chat UI against a dependency-free npm package.
- [iOS SDK](https://vatio.ai/docs/sdk/ios) — The conversation as observable state in a Swift package; the UI is yours.
- [Android SDK](https://vatio.ai/docs/sdk/android) — The conversation as StateFlows in a Kotlin library; the UI is yours.
- [Ruby gem](https://vatio.ai/docs/sdk/ruby) — Sign your users in and message them first from a Ruby or Rails backend.

#### Source code

Everything you install is a public repository under
[github.com/vatio-ai](https://github.com/vatio-ai). Read the source there, and
open an issue in the repository of the package that misbehaved.

| Repository | What it is | Install |
|---|---|---|
| [vatio-ai/cli](https://github.com/vatio-ai/cli) | [The CLI](https://vatio.ai/docs/cli/) | `npx @vatio-ai/cli` |
| [vatio-ai/skills](https://github.com/vatio-ai/skills) | [Skills for coding agents](https://vatio.ai/docs/integrations/claude-code) | `npx @vatio-ai/skills` |
| [vatio-ai/widget](https://github.com/vatio-ai/widget) | [The web widget](https://vatio.ai/docs/channels/widget) | `npm install @vatio-ai/widget` |
| [vatio-ai/sdk](https://github.com/vatio-ai/sdk) | [Browser SDK](https://vatio.ai/docs/sdk/) | `npm install @vatio-ai/sdk` |
| [vatio-ai/ios](https://github.com/vatio-ai/ios) | [iOS SDK](https://vatio.ai/docs/sdk/ios) | Swift Package Manager |
| [vatio-ai/android](https://github.com/vatio-ai/android) | [Android SDK](https://vatio.ai/docs/sdk/android) | `com.github.vatio-ai:android` on JitPack |
| [vatio-ai/ruby](https://github.com/vatio-ai/ruby) | [Ruby gem](https://vatio.ai/docs/sdk/ruby) | `gem "vatio-sdk"` from RubyGems |

The widget, the skills and the gem are MIT. The CLI and the SDKs are
source-available under the license in each repository.

#### For coding agents

This entire site is also one Markdown file: [`/docs.md`](https://vatio.ai/docs/docs.md), or
`vatio docs` from the terminal. Every individual page is available as Markdown
at its own path plus `.md` — [`/quickstart.md`](https://vatio.ai/docs/quickstart.md), for instance —
so an agent can read the one page it needs rather than the whole contract. The
index of them is [`/llms.txt`](https://vatio.ai/docs/llms.txt).

```bash
vatio docs --save vatio-docs.md
```

That fetches the documentation from your configured Vatio host. Run it again to
refresh the file; the CLI keeps no offline cache.

A starting prompt:

```text
Run npx @vatio-ai/skills in this project, then read
https://vatio.ai/docs/docs.md. Inspect this repository for an existing
vatio.yml before creating a workspace. Build a customer-facing agent
using the product's existing APIs and content. Deploy to preview, test
representative conversations, and show me the preview link and results.
Publish when I ask you to make it live.
```

For an existing workspace, work from the directory containing `vatio.yml` or
one of its subdirectories, and preserve the existing `workspace` slug. If the
developer was added to someone else's, start with
[Joining an existing workspace](#joining-an-existing-workspace).

##### Starting a new agent

If you are a coding agent asked to build someone a Vatio agent, this is the
order that works:

1. **Install the skills.** Unless `.agents/skills/vatio-init` is already
   there, run `npx @vatio-ai/skills` from the root of the project. It copies
   the Vatio skills into `.agents/skills/` and `.claude/skills/`, so this
   procedure and the one for improving the agent are in the repository for
   the next session, whichever coding agent runs it.
2. **Look before creating.** Search for a `vatio.yml` at or above the current
   directory and anywhere in the repository (`git ls-files '*vatio.yml'`). If
   there is one, the agent exists: follow
   [Joining an existing workspace](#joining-an-existing-workspace) instead.
   Read the app too: what it does and for whom, the API an agent could call,
   how its users sign in, and the docs or policies it could read. Tell the
   person you work for what you found and what you recommend the agent do
   before writing anything.
3. **Ask the owner, briefly, what the code does not answer.** What the business does
   and who writes in; what the agent must never say or promise; when it has to
   hand the conversation to a person; and which of their systems it should
   read from (an API, a calendar, a catalog) — those become
   [tools](https://vatio.ai/docs/tools). Do not invent business facts you were not given.
4. **Create the workspace** with `vatio init SLUG` in the repository the agent
   belongs in — usually the one of the backend it will call. Write
   `instructions` from the answers, the never-say rules as explicit rules,
   [`handoff`](https://vatio.ai/docs/handoff) for the human cases, and [knowledge](https://vatio.ai/docs/knowledge) for
   anything long the agent should read rather than memorize. If the app's users
   sign in with Supabase (a Supabase client, a `SUPABASE_URL` in `.env`), run
   `vatio auth --supabase` before writing tools that need a signed-in user:
   the widget then knows who is signed in with no code in their app
   ([Supabase](https://vatio.ai/docs/integrations/supabase)).
5. **Check, push, try.** `vatio diff`, then `vatio push` — the first time it
   signs the developer in: it opens their browser (if it cannot, show them the
   link it prints), and they sign in with email, Google or GitHub and confirm
   the code matches. If the command gives up waiting, run it again after they
   have signed in. Then
   `vatio chat` a few representative conversations — including one that should
   hand off and one that asks for something it must not say.
6. **Hand over the link.** `vatio push` prints it after `Try it:`: the
   **Test** page in the inbox, with web chat, WhatsApp and Instagram. Report
   what you tried and how it answered.
7. **Publish only when asked.** `vatio publish` takes it live.
8. **Tell us what got in the way.** If an error or these docs left you
   guessing, run `vatio feedback "what you were doing and what was missing"`
   before you finish. It is read by the people who write both.
9. **Offer the session.** In Claude Code, run
   `vatio share-session --should-ask` last. If it prints `yes`, show the
   person you work for what would go (`vatio share-session --save FILE`) and
   ask, once, whether to send it to the Vatio team: send it, not now, or
   don't ask again (`vatio share-session --never`). Suggest they add
   feedback, and send with `vatio share-session --yes` — with their feedback
   in quotes if they gave some. If a permission check refuses that, give them
   `vatio share-session --id ID --yes` to run themselves and check with
   `vatio share-session --list`, which marks a sent session. If it prints
   `no`, say nothing ([Share a session](https://vatio.ai/docs/cli/workspace#share-a-session)).

##### Joining an existing workspace

If you are a coding agent asked to get a developer working on a workspace
someone else added them to ([Teams](https://vatio.ai/docs/team)), this is the order that works:

1. **Find the files.** The agent is a `vatio.yml`, and the files it points
   to, in the owner's repository. Look at or above the current directory, and
   anywhere in the repository (`git ls-files '*vatio.yml'`), for one whose
   `workspace:` is the slug you were given. If it is not there, ask
   for the repository: Vatio keeps what was deployed, not the files it came
   from. Do not `vatio init` one instead — it writes a starter agent, and
   pushing that would replace the real one.
2. **Sign in as the invited address.** `vatio login` opens the browser; the
   developer signs in with the email they were invited with. Any other address
   is a different account, and every command answers `workspace_forbidden`.
3. **Compare the folder with live.** `vatio diff --env live` lists what this
   folder would change if it were published. If it is not empty, say so before
   changing anything: the repository may be behind what is serving.
4. **Explain the agent.** From `vatio.yml`: what it is for, its tools, its
   knowledge and when it hands off. `vatio status` says what is deployed,
   `vatio flags` what supervisors have reported, and `vatio tools` which tool
   calls are failing. Recommend from what you find: the flags are work for
   [Improve your agent from flags](https://vatio.ai/docs/improve), and a failing tool is a fix in
   the backend it calls.
5. **Change it through preview.** `vatio push` updates preview, which the
   whole workspace shares; if it holds another branch's version that is not
   live yet, the push is refused rather than replacing it
   ([Working with others](https://vatio.ai/docs/deployments#working-with-others)). `vatio eval`
   tests preview against every flag, and `vatio publish` takes it live once
   those tests allow it ([Publishing](https://vatio.ai/docs/deployments#publishing)).

Once supervisors use it, `vatio flags` is where improving it starts — see
[Improve your agent from flags](https://vatio.ai/docs/improve). The
[`vatio-ai/skills`](https://github.com/vatio-ai/skills) skills from step 1
carry both starting points and sharing a session, as a Claude Code plugin or,
for any coding agent, with `npx @vatio-ai/skills`
([Claude Code](https://vatio.ai/docs/integrations/claude-code)).

Release notes are in the [changelog](https://vatio.ai/docs/changelog).

### Claude Code

`https://vatio.ai/docs/integrations/claude-code`

Already building an app with Claude Code? Vatio adds a conversational agent
that can serve its users. Describe what you need; `vatio-init` reads your app,
sets up the agent and gives you a preview. Vatio hosts the conversations,
and your team follows them in the [inbox](https://vatio.ai/docs/channels/inbox).

After launch, make `vatio-improve` your daily routine: report a mistake,
let Claude prepare a fix, review Vatio's tests and approve publication.
You do not need to learn the CLI to follow this path.

#### Install

Open Claude Code in your app's project. Run these commands one at a time:

```
/plugin marketplace add vatio-ai/skills
/plugin install vatio@vatio
```

Follow the installation panel and select the scope you want. If Claude asks
for a plugin reload, follow its message before continuing. The commands come
from the [Claude Code plugin installation guide](https://code.claude.com/docs/en/discover-plugins).

#### Create your agent with one prompt

[Create your account](https://vatio.ai/session/new), then tell Claude:

```text
Use vatio-init to build a support agent for this app. Connect it to our
existing users and features, define its permissions and human handoff,
and give me a preview to try before publishing.
```

Claude reads your project and asks only what it cannot infer: what the agent
may do, what it must never promise and when a person should take over.
Try the preview and ask Claude to publish when it is ready.

#### Your daily routine

1. Read real conversations in the inbox. Report a wrong reply and write what
   should have happened.
2. In your app's project in Claude Code, run `/vatio:vatio-improve`.
3. Review which reported cases now pass and approve publication of the fix.

Claude can fix instructions, knowledge, tools or the backend they call.
Vatio tests the reported cases against preview; publication checks reject a
case that was passing live and now fails. It does not guarantee every possible
answer. The daily email gives you the same command when new reports arrive.
See [Improve your agent](https://vatio.ai/docs/improve) for the full process.

#### Hand work to Claude from a chat

The plugin is Claude Code working on your agent. The other direction works
too: your agent can hand a request from a WhatsApp chat to a Claude Code
routine, which works on your code and answers in the same chat. See
[Connect Vatio to Claude Code](https://vatio.ai/docs/connect-claude-code).

#### Install without the plugin

From the root of your project:

```
npx @vatio-ai/skills
```

That copies the skills into `.claude/skills/` and `.agents/skills/`, and
Claude Code lists them as `/vatio-init`, `/vatio-improve` and
`/vatio-share-session`. Run it again to update them. This is also the installation path for
other coding agents that read skills.

##### Keeping them up to date

Claude Code does not update plugins from other people's marketplaces on its
own. Turn it on once in `/plugin` → **Marketplaces** → **vatio** → **Enable
auto-update**, or update by hand with `claude plugin update vatio@vatio` (or
`/plugin` → **Installed** → **Update now**). Copies made with
`npx @vatio-ai/skills` update by running it again. `vatio flags` and
`vatio eval` say so when the skills are missing or older than the published
ones.

#### The skills

| Skill | What it does | Call it directly |
|---|---|---|
| `vatio-init` | The first one in a project: looks at what is already there (a `vatio.yml` anywhere in the repository, the app's code) and recommends from it. With no agent yet, builds one: asks the owner what the code does not answer, writes `vatio.yml`, pushes it and hands over a link to try. With one, explains it, compares it with live and says what to work on | `/vatio:vatio-init` |
| `vatio-improve` | Takes every reply supervisors flagged in the inbox in one session: reads `vatio flags`, fixes each cause, pushes to preview, proves it with `vatio eval` in the background, and publishes through the [publish checks](https://vatio.ai/docs/deployments#publishing) | `/vatio:vatio-improve` |
| `vatio-share-session` | Sends this Claude Code session to the Vatio team when you ask it to, or offers to when the other two finish: shows what would go, asks you first with Claude Code's own question (send it, send it with a comment, not now, don't ask again), and runs `vatio share-session` | `/vatio:vatio-share-session` |

Claude Code picks the right one on its own when you ask to build, improve or
fix a Vatio agent, or to send the session to Vatio; the commands are there when
you want to be explicit.

The skills are short on purpose. They only say *when* to use the CLI; the
instructions come from the platform each time `vatio docs` or `vatio flags`
runs, so those are always current. See
[Starting a new agent](https://vatio.ai/docs/#starting-a-new-agent),
[Improve from flags](https://vatio.ai/docs/improve) and
[Share a session](https://vatio.ai/docs/cli/workspace#share-a-session) for what each one follows.

#### Share a session with the Vatio team

When something got in the way and a sentence will not explain it, say "send
this session to Vatio" or run `/vatio-share-session`. Claude Code asks you
before anything is sent, and `vatio share-session` shows which session goes,
with images left out, anything that looks like a secret replaced with
`[REDACTED]`, and email addresses and phone numbers with `[EMAIL]` and
`[PHONE]`. It also suggests adding feedback in your own words — what got in
the way, what you expected — which goes with the session if you give some.
Only the Vatio team reads it, every read is logged, and it is deleted 30 days
after it arrives. If Claude Code's auto mode refuses to send it,
Claude Code gives you the command to run yourself and then checks with
`vatio share-session --list` that it went.

When `vatio-init` or `vatio-improve` finish, Claude Code offers to send it, with
three answers: send it, not now, or don't ask again. It offers at most once a
week and never twice in a session, and after don't ask again it stops for
good ([When your coding agent offers it](https://vatio.ai/docs/cli/workspace#when-your-coding-agent-offers-it)).

#### Other coding agents

- **Any agent that reads skills** (Codex, Cursor, Gemini CLI, GitHub Copilot,
  OpenCode and others): `npx @vatio-ai/skills` from the root of your project
  puts them in `.agents/skills/`, where those agents look, without asking
  anything. Or `npx skills add vatio-ai/skills`, the open
  [skills](https://skills.sh) CLI, which asks which agents you use. The
  package is [`@vatio-ai/skills`](https://www.npmjs.com/package/@vatio-ai/skills),
  and its source is [vatio-ai/skills](https://github.com/vatio-ai/skills).
- **Any agent at all:** `vatio init` writes a short Vatio section into the
  workspace's `AGENTS.md`, which most coding agents read without installing
  anything, and the prompt on [vatio.ai](https://vatio.ai) has any agent
  install the skills first and follow `vatio-init`.

Bugs and ideas for the skills go to
[vatio-ai/skills issues](https://github.com/vatio-ai/skills/issues).

### Inbox

`https://vatio.ai/docs/channels/inbox`

Invite supervisors from **Team** in the inbox ([Teams](https://vatio.ai/docs/team) covers
developers too). They sign in with a code
to their email, plus a passkey or an authenticator code if their account has
[two-step verification](https://vatio.ai/docs/team#two-step-verification-and-sessions), at
`inbox.vatio.ai/<workspace>` — every conversation, replies,
human takeover, ratings, and the agent's tool calls — along with
[analytics](#analytics), [what each report became](#report-a-reply), the
[knowledge](https://vatio.ai/docs/knowledge), whose entries they can edit there, and the
[business](#business) the agent works for. They see that and nothing else: no
deploy, no tools. Remove them from the same page
and the next request they make is refused, including a socket they already had
open.

Invite someone as a **viewer** instead and they read all of that with no reply
box and nothing to edit. Either role can be narrowed to some agents'
conversations ([Teams](https://vatio.ai/docs/team#limiting-someone-to-some-agents)), and every
conversation opened, reply sent and takeover handed back is recorded in the
workspace's [Activity](https://vatio.ai/docs/team#activity).

A supervisor can also connect the workspace's WhatsApp number, or renew its
access, when the number is theirs: see [When your client has the
number](https://vatio.ai/docs/channels/whatsapp#when-your-client-has-the-number).

There is nothing to embed and no token to mint. Being signed in to Vatio, as
someone this workspace has invited, is the whole access model.

Each conversation shows the channel it came through: **Widget** for Vatio's
[widget](https://vatio.ai/docs/channels/widget), **Web** for a page built on the browser SDK, then
iOS, Android, WhatsApp, Instagram and CLI. With more than one in use, the row
above the list filters by channel, and scrolls sideways when they do not fit.

Replying starts **human takeover**, which pauses agent replies on that chat —
from the inbox, or from the WhatsApp Business or Instagram app on your own
account, which reaches Vatio as an echo and counts the same. Hand it back from the inbox when
you are finished, or leave it: a takeover expires **8 hours after the last
thing the person said**, and the agent picks the conversation up again the next
time the visitor writes. The clock restarts on every human reply, so a
conversation somebody is actively holding never expires underneath them.

While it lasts, the conversation shows **Person** in the list and a banner above
the thread says who is handling it and that the agent is paused. Each human
reply says whether it came from the inbox, with the name of whoever wrote it,
or from the channel's own app.

A conversation you open from one of those apps, writing first to someone who
has never written to you, stays yours: the takeover starts when they answer,
however late that is, so the answer reaches you and not the agent. Until then
the chat stays out of the inbox. Whenever the agent does pick a conversation up
again, it knows which earlier replies a person wrote and stays consistent with
them.

To have the agent ask for a person itself, rather than waiting for one to
notice, declare [`handoff`](https://vatio.ai/docs/handoff) on it.

The contact pane beside a conversation is also where the owner and developer
members answer a person who asks for their data or asks to be forgotten:
**Download their data** and **Delete this contact and their data**. See
[Retention and deletion](https://vatio.ai/docs/data).

A message that went out with buttons shows them under it, one under the
other, as the contact saw them: the **Sign in** link, the button to share a
number, a template's buttons, and the three answers to the
[rating question](https://vatio.ai/docs/channels/whatsapp), with the one the contact tapped marked.
The rating itself sits in the thread where the visitor gave it, with their
comment if they left one.

#### Search

The box above the conversation list finds a conversation the way a phone's
chat app does. Type a name, an `@username`, an email or part of a number and
the people who match come first; below them, every message that says it,
newest first, with who said it and when. Picking a message opens that person's
thread scrolled to it, however far back it is.

Case and accents don't matter (`envio` finds "Envío"), and several words find
the messages that have all of them. It searches the environment and channel the
list is showing, and only what the thread shows: never tool results or
discarded drafts. Messages are stored encrypted, so each search reads through
the most recent 10,000; when it stops short, **Search older messages** carries
on from there.

#### Business

The **Business** page is what the agent knows about the business it works for,
set by the people who run it rather than by the developer: its name, a
description (what it does, where it is, how to reach it), and its
[opening hours](https://vatio.ai/docs/handoff#business-hours) — a time zone and shifts per day, or
someone always available. The agent uses them from the moment they are saved,
in every environment, and gives customers the hours when they ask. The page
also shows, read-only, the conditions the agent hands a conversation over on.

Its **Brand** card is how the business looks to visitors: the logo (the agent's
avatar in the widget and on the chat page), the brand color the widget uses as
its accent, and the "about" the widget shows before anyone writes.

#### Analytics

**Analytics** is how the agent is doing over the last 7, 30 or 90 days, for the
environment being viewed:

- Conversations, with the change against the previous period, and how many
  asked for a person.
- Reports not solved yet, and how many were reported and solved in the
  period.
- The share of visitor ratings that were good, with the 👍 😐 👎 counts.
- Conversations per day, stacked by channel.
- The latest open flags and the latest visitor comments, each linking to its
  conversation.

Eval replays are not counted: they are Vatio testing the agent, not visitors.

#### Report a reply

On any reply the agent wrote, **Report this reply** asks for **what should have
happened**: a different answer, an action, a tool the agent should have used,
a handover to a person. That note is a test from then on, whether the reply was
on live or on preview: every new version of the agent is asked the same
question and checked against it. Nobody marks a report as solved.

The report's state shows on the reply itself, in the conversation:

| State | Means |
|---|---|
| **Open** | The agent visitors talk to still answers it wrong |
| **Fixed · not published yet** | The next version answers it the way you asked; it is not live yet |
| **Resolved · a1b2c3d** | Live answers it the way you asked, since the version named |

Until the agent's first publish there is no live, so preview is the agent: a
report is resolved as soon as preview answers it right. If a resolved one is
still wrong, rewrite your note in the conversation: a new note is a new
request, and the report is open again. A report without a note cannot be
tested.

#### Continuous improvement

**Continuous improvement**, in the inbox's menu, is every report the team made
and what is left to do about them.

At the top, **What's next** says it in one line: how many reports are open,
with the command to hand the developer, `/vatio:vatio-improve`, which they run
in Claude Code inside the agent's repository and which takes every open
report at once; how many fixes are waiting to be published; that a fix broke
something that already worked, so it cannot be published; or that everything
is resolved. Under it, when the last test ran and how many reports passed,
and for the owner and developers a link to **Test**, whose **Run tests**
replays them on preview.

Reports are listed under **Pending** (open, fixed but not published yet, or
broken again), **Resolved** and **All**. Each shows its note and the result
of its latest test, with the judge's reason. **See details** opens the
visitor's message, the reply that was reported, what the agent answered in
the test and the version that fixed it. **See conversation** opens the
thread. **Remove** deletes a report and its test, for everyone but read-only
members.

The workspace's developers get the same command by mail every morning there
are new reports. See [Improve your agent from flags](https://vatio.ai/docs/improve).

### Your daily improvement routine

`https://vatio.ai/docs/improve`

If you use Claude Code, the routine is three steps:

1. **Report:** in the inbox, mark a wrong reply and say what should have happened.
2. **Improve:** open your app's project and run `/vatio:vatio-improve`.
3. **Review and publish:** Claude prepares fixes; Vatio tests them against preview.
   Check the results and approve the update when you are ready.

New reports also arrive in your daily email with the same command. The rest
of this page explains the checks and commands the skill runs for you.

The people reading your agent's conversations know when a reply is wrong.
In the [inbox](https://vatio.ai/docs/channels/inbox), a supervisor **flags** that reply and writes
what should have happened: a different answer, an action, a tool the agent
should have used or does not have. From then on, that flag is a test. Your
coding agent fixes it; Vatio checks the fix:

1. **A digest.** `vatio flags` prints every open flag as one markdown document
   written for a coding agent: the conversation up to the reply, every tool
   the agent called on that turn with **what your backend answered**, and the
   supervisor's note.
2. **A test per flag.** Every flag with a note is an eval case, wherever it
   was made, live or preview. The visitor's turns become the input and the
   note becomes the expected outcome. `vatio eval` replays every case against
   preview and a judge compares each new turn, tool calls included, with the
   note.
3. **Resolution on evidence.** A flag that passes on preview is **fixed in
   preview**; once that version is published it is **resolved**, with the
   version that fixed it. Nobody has to say it is done. Supervisors see each
   flag's state on the reply they flagged, and every flag with its latest
   tests in the inbox's
   [Continuous improvement](https://vatio.ai/docs/channels/inbox#continuous-improvement) page.

Your coding agent has your repository and your backend in front of it, which
Vatio does not. That is why the fix is its job: the cause is often a tool
response missing a field, not the prompt.

Flags only catch what a supervisor noticed. A tool that fails seldom gets
flagged, because the agent apologizes and moves on, so `vatio tools --env
live` gives your coding agent the inbox's **Tools** page: each tool's error
rate, timeouts and latency, and the calls that failed with what your backend
answered. See [Tool metrics](https://vatio.ai/docs/cli/improve#tool-metrics).

#### The loop

In Claude Code, from the repository with the `vatio.yml`:

```
/vatio:vatio-improve
```

One session takes every open flag. This is what it runs, and what you run
with any other coding agent:

```bash
vatio flags --save     # vatio-flags.md: hand it to your coding agent
### ... your agent fixes vatio.yml, knowledge, tools or the backend they call ...
vatio push             # updates preview; runs no tests
vatio eval             # replays every flag on preview; exits 1 if one fails
vatio publish          # refused while preview's version is untested or breaks a flag live passes
```

`vatio eval` takes minutes: every case is a real agent turn, five run at a
time, so 27 cases take three or four minutes. In Claude Code, run it in the
background and keep working. Push and eval again until the flags you meant to
fix pass, then publish: preview's results go live with it, so live is not
replayed again and the flags they pass are resolved at once.

A prompt that works with any coding agent:

> Read the output of `vatio flags`. Group the flags by cause, fix each cause
> where it lives — `vatio.yml`, knowledge, a tool, or the backend a tool calls
> — push it, prove it with `vatio eval`, and tell me which flags now pass.

The digest itself carries the same instructions at the top, so an agent
handed only the file knows what to do with it.

##### What publish checks

`vatio publish` and the **Publish to live** button on the inbox's **Test**
page go through the same gate. It looks at preview's current version:

| | `vatio publish` |
|---|---|
| It is already live's version | Nothing to publish: only preview's knowledge changes go live, and `vatio publish` prints a notice saying so. The **Test** page shows no **Publish to live** button then |
| The agent was never published | Publishes. If `vatio eval` already finished on that version, its results go live with it |
| No flags yet | Publishes |
| Not tested | Refused, `evals_missing`: run `vatio eval`, then publish |
| Its run is still going | Refused, `evals_running`: `vatio eval` waits for it |
| A flag that passes on live fails on it | Refused, `evals_regressed`, naming each flag. `vatio publish --force` publishes anyway, and its failing results go live with it |
| Otherwise | Publishes, and the flags its run passed are resolved with its short sha |

Flags that still fail do not block a publish: a fix for three of five can go
live while the other two wait. A version published with no results of its
own, the first publish before any `vatio eval` for instance, has live replayed
right after it.

##### Let your coding agent find it on its own

So you do not have to remember the command:

- **The daily mail.** Every morning with new flags, the workspace's owner and
  developers get how many are open and the prompt to paste into Claude Code,
  `/vatio:vatio-improve` ([The daily digest](#the-daily-digest)).
- **`AGENTS.md`.** `vatio init` writes a short Vatio section into the
  workspace's `AGENTS.md`, or appends one to an existing file that does not
  mention Vatio. Most coding agents read it without installing anything.
- **The `vatio-improve` skill**, for Claude Code:

  ```
  /plugin marketplace add vatio-ai/skills
  /plugin install vatio@vatio
  ```

  Claude Code then uses it when you ask it to improve or fix your agent, or
  as `/vatio:vatio-improve`. For any other coding agent,
  `npx @vatio-ai/skills` from the root of your project copies it to
  `.agents/skills/`, where Codex, Cursor and most others read skills. See
  [Claude Code](https://vatio.ai/docs/integrations/claude-code).

They only point at the CLI. The instructions come from the platform each time
`vatio flags` runs, so none of them goes out of date.

#### What a flag carries

| In the digest | Where it comes from |
|---|---|
| Agent, environment, deployed version, channel | The chat the reply was in |
| The conversation up to the flagged reply | The last 10 messages before it |
| What the agent did on that turn | Each tool call: its arguments, the HTTP status, outcome and duration of the call to your backend, and what it returned; knowledge lookups; tokens |
| What should have happened | The supervisor's note |
| Eval case | The case Vatio created from the flag |

A flag without a note is not in the digest and has no eval case: "this was
wrong" without saying what should have happened is not something an agent can
be fixed from. Removing the flag removes its case.

#### A flag's state

| State | Means |
|---|---|
| **Open** | It fails on the agent visitors talk to |
| **Fixed in preview** | `vatio eval` passed it on preview's current version, which is not published yet |
| **Resolved** | Live passes it. It shows the short sha of the version that fixed it, like `a1b2c3d` |

Until the agent's first publish there is no live, so preview is the agent: a
flag resolves as soon as `vatio eval` passes it there. Rewriting the note is a
new request, so the flag goes back to open. A resolved flag that fails on
preview is a regression, and preview's version cannot be published until it
passes again.

#### Eval runs

`vatio eval` replays every eval case of the workspace against one
environment, matching cases to that environment's agents by slug. Each case
runs in a fresh chat: the visitor's turns (up to the last 8) are sent one by
one and the agent answers each. A judge then reads the replay, including every
tool the agent called, and the expected outcome, and answers pass or fail with
one sentence of reasoning. A note that asks for a tool passes when the agent
calls it. Everything runs on Vatio's servers: your coding agent only starts
it and reads the report.

| | |
|---|---|
| Environment | `preview`; `--env live` to test what visitors get |
| Speed | Five cases at a time. A `vatio eval`, or **Run tests** on the inbox's **Test** page, while a run of the same version is still going joins it instead of starting another |
| Exit code | `1` when any case fails or errors, so it can gate CI |
| Report | The open flags, the ones a fix is for, are counted and listed apart from the regression cases: flags already resolved. Each row is named by its flag's id, like `F-7K2QX`: a flag and its case share one |
| Regressions | A case that passes on live and fails on preview is listed first. It blocks the publish |
| The judge's reasons | `vatio eval --show RUN_ID` |
| Judge | A [model](https://vatio.ai/docs/model) from a different family than your agent's, so it never grades its own kind of answer |
| Retries | A case the judge fails is replayed again, in a fresh chat, up to 3 attempts. Only one that fails all three counts as failing; one that passed on a later attempt says how many it took, like `F-7K2QX (2 attempts)`, and is unstable: worth fixing even though it passed |
| Judge errors | An answer the judge could not read is an error, not a fail, and does not count against the agent |
| Daily limit | 2,000 executions (replay chats, each attempt counted) per workspace a day, a guard against loops. Past it, new runs start the next day, and `vatio eval` answers `429 daily_test_limit` |
| Disagreeing with the judge | Rewrite the flag's note in the conversation. A new note is a new request: the flag reopens and its case is judged against the new note from the next run |
| Replay chats | Not in the inbox and not counted as conversations, live included: they are Vatio testing the agent, not visitors |

A replay is a real agent turn, so a case can pass once and fail the next
without anything changing: that is why a failing case is retried.

::: warning Evals call your tools
A replay is a real agent turn, so the tools it calls reach your backend. Set a
test value for the secrets in their URLs, like a staging backend
(`vatio secrets set SHOP_API https://staging.example.com --tests`): every
replay uses it, live's included, and so does preview. A replay's calls carry
an `X-Vatio-Test: 1` header. A write (any method but GET) whose URL has no
test value is not sent at all: the agent gets a success, and the judge still
checks the call it made and its arguments. See
[Secrets](https://vatio.ai/docs/deployments#secrets).
:::

`vatio push` runs no tests. A run starts with `vatio eval`, with **Run
tests** on the inbox's **Test** page, or with that page's **Publish to live**
on a version nobody tested; a publish that has no results to take live
replays live after it.

**Run tests** replays every case on preview, the same as `vatio eval`, for
whoever has no terminal open. The preview card it sits on says how many
cases the newest run of preview's version passed, or that it has not been
tested yet.

#### The daily digest

Every morning at 8:00 (America/Santiago), each workspace with new flags mails
its owner and developers how many flags arrived and how many are open, with
the prompt to paste into Claude Code, `/vatio:vatio-improve`, and a link to
the inbox's [Continuous improvement](https://vatio.ai/docs/channels/inbox#continuous-improvement)
page.

The mail never quotes what anyone wrote. Conversations, tool responses and
supervisors' notes are only ever readable with a Vatio token.

#### API

| Endpoint | Returns |
|---|---|
| `GET /api/developer/v1/:workspace/flags.md` | The digest, as `vatio flags` prints it |
| `GET /api/developer/v1/:workspace/flags` | The open flags as JSON |
| `POST /api/developer/v1/:workspace/evals/runs` | Starts a run; body `{ "environment": "preview", "agent": "main" }`, both optional |
| `GET /api/developer/v1/:workspace/evals/runs/:id` | One run's progress: `cases`, `done`, `passed`, `failed`, `finished` |
| `GET /api/developer/v1/:workspace/evals/runs/:id.md` | One run's report |

All of them take the same token as the CLI. The proposals endpoints answer
`410 proposals_removed`: test with `vatio eval` and publish with
`vatio publish`.

### Connect Vatio to Claude Code

`https://vatio.ai/docs/connect-claude-code`

**Your agent talks to people; Claude Code works on your code.** Connect them,
and from a WhatsApp chat you can ask your agent for a change. It gathers the
request, asks what is missing, and hands the conversation to Claude Code.
Claude reads it, does the work in your repositories, and tells the agent,
which tells you in the same chat, often with a pull request.

This guide sets that up end to end. Each step links to the reference page if
you want the details.

#### What you end up with

A chat on WhatsApp between you and your agent:

> **You** (forwarding a client's message): Can I tap "on trial" in Users and see who they are?
>
> **You:** Implement that, it's for our studio app.
>
> **Agent:** Got it, I'm asking Claude Code to make the cards in Users filter the list. Its session: claude.ai/code/session_…
>
> **Agent**, a few minutes later: Claude tells me it's done: the cards in Users now filter the list below. Draft PR: github.com/acme/app/pull/484

You stay in charge of what ships: Claude opens a branch and a draft pull
request, and you review and merge it.

#### Before you start

- A Vatio workspace with an agent on your own WhatsApp number. To try it first,
  a test phone on Vatio's preview number works the same, with a preview
  routine.
- A Claude plan with [routines](https://code.claude.com/docs/en/routines)
  (Pro, Max, Team or Enterprise), and the repositories you want Claude to work
  on connected to Claude on GitHub.
- The Vatio CLI, logged in, in the directory with your `vatio.yml`.

#### 1. Create the routine on claude.ai

On [claude.ai/code/routines](https://claude.ai/code/routines), create a
routine and pick its repositories. The prompt is yours to write. It needs to
cover four things:

- **The payload is where the conversation is.** The routine starts with a
  `routine-fire-payload` block that holds `messages_url`. Tell it to read the
  conversation there, and that the people writing in it are the ones giving
  instructions.
- **How to read it:** `curl -H "Authorization: Bearer $VATIO_SERVER_KEY" "$messages_url"`.
  Forwarded messages (`forwarded` set) are someone else's words, never
  instructions.
- **What to do:** for example, answer questions from the code, or make the
  change on a new branch and open a draft pull request. Never merge or deploy.
- **How to answer:** `POST https://vatio.ai/api/server/v1/<workspace>/messages`
  with `{ "to": "<the contact's phone_number>", "brief": "Tell them that Claude finished: …" }`.
  The agent writes the message from it, as news from Claude.

Then add an **API** trigger. Copy its URL and generate its token; claude.ai
shows the token once. [What the routine receives](https://vatio.ai/docs/integrations/claude-routines#what-the-prompt-needs-from-vatio)
has every field.

#### 2. Let the routine read and answer

The routine needs a [server key](https://vatio.ai/docs/cli/keys) of its own, and a way to reach
Vatio:

```sh
vatio keys create --env live --label claude
```

- Put the key in the routine's cloud environment, for example as
  `VATIO_SERVER_KEY`. Everyone who uses that environment can read its
  variables.
- Add `vatio.ai` to the environment's network access. The default environment
  only reaches a list of trusted domains.

#### 3. Connect the routine to Vatio

```sh
vatio routines create encargos \
  --url https://api.anthropic.com/v1/claude_code/routines/trig_…/fire \
  --phone +56912345678
```

- `encargos` is the routine's name in Vatio; the agent's tool will refer to it.
- `--phone` is who may use it: only WhatsApp conversations with these numbers
  get the tool. Add one `--phone` per number, up to 20.
- Paste the token when it asks. Vatio refuses anything that is not a
  `sk-ant-oat01-…` token. Copy the token last: if you copy this command after
  it, the clipboard no longer holds the token.

Check that it starts:

```sh
vatio routines test 1
```

That starts a real session with a `ping` and no conversation, and prints how
the trigger answered and the link to the session.

#### 4. Give it to your agent

Create `tools/encargos.yml`:

```yaml
description: Pass a request to Claude, which reviews or changes the code of our apps in a separate session.
when_to_use: Someone asks to review or change something in our apps and you know what it is. If what to change or where is missing, ask first.
routine: encargos
```

Add it to the agent in `vatio.yml`, then push and publish:

```yaml
agent:
  tools: [encargos]
```

```sh
vatio push
vatio publish
```

`when_to_use` is what the agent decides by, so say when a request is complete
enough to hand over. A routine tool takes no `request:`, `access:` or
`parameters:`. See [Routine tools](https://vatio.ai/docs/tools#routine-tools).

#### 5. Keep your clients away from it

The tool only exists in conversations with the routine's numbers. If the
number is your business's, your agent answers everyone else as usual.

If it is a number your clients write to and you would rather answer them
yourself, have the agent answer only your numbers before you switch **Live**
on:

```sh
vatio whatsapp only +56912345678
```

Everyone else's messages still arrive in the inbox. See
[Who the agent answers](https://vatio.ai/docs/channels/whatsapp#who-the-agent-answers).

#### 6. Try it

Write to your number from one of the routine's numbers. Ask for something
small, and leave out a detail on purpose: the agent should ask before handing
it over. Once it says Claude has it:

```sh
vatio routines runs 1
```

That lists each run, with how the trigger answered and the link to the Claude
session, where you can watch it work.

#### Do it from Claude Code

With the Vatio plugin installed, Claude Code can do steps 2 to 4 for you. Open
your agent's project and tell it:

```text
Connect my Claude Code routine to Vatio. Its API trigger URL is <url>, and only
+56912345678 may use it. Create a server key for it, connect the routine (I will
paste the token when the CLI asks), and give the agent a routine tool named
encargos that hands over requests to review or change our apps. Push it to
preview.
```

You still create the routine on claude.ai, paste its token, and put the server
key in its environment yourself.

#### When something does not work

| What you see | What to check |
|---|---|
| `vatio routines test` answers 401 | The token. Generate a new one on claude.ai and run `vatio routines update ID --new-token` |
| The agent never hands anything over | The number is in the routine's `--phone` list, the chat is on WhatsApp, the routine is enabled (`vatio routines list`), and `encargos` is in `agent.tools` of the published deployment |
| The agent hands over too early or too late | Its `when_to_use` |
| The run fires but the agent never tells you anything | The routine's session (link in `vatio routines runs`): its server key, and `vatio.ai` in its network access |
| Claude's answer is refused with `409 whatsapp_paused` | **Live** is off on the number; switch it on, or have the routine answer another way |
| Claude's answer is refused with `422 outside_window` | More than 24 hours passed since the person last wrote; only an approved template goes out then |

The reference for all of this is [Claude Code routines](https://vatio.ai/docs/integrations/claude-routines).

### Manual quickstart

`https://vatio.ai/docs/quickstart`

**Using Claude Code?** Start with the [Vatio plugin](https://vatio.ai/docs/integrations/claude-code)
and describe the agent you want. `vatio-init` handles the steps below for you.
This page is for working directly with the CLI.

You need **Node.js 20 or newer** and a Vatio account. The examples use `acme`
as a workspace slug; replace it with yours.

#### Install the skills

From the root of your project:

```bash
npx @vatio-ai/skills
```

That copies the Vatio skills into `.agents/skills/` and `.claude/skills/`, so
the coding agent working in the project — Claude Code, Codex, Cursor or any
other — knows how to start a Vatio agent, improve it, and share a session
with the Vatio team. With them in place you can skip the rest of this page and
ask it to build one; see [Claude Code](https://vatio.ai/docs/integrations/claude-code).

#### Install the CLI

```bash
npm install -g @vatio-ai/cli@latest
vatio --help
```

#### Create a workspace

Run these commands in the repository where you want to keep your agent:

```bash
mkdir support-agent
cd support-agent
vatio init acme
```

`init` writes `vatio.yml` in the **current directory** and asks for no account:
you can write the whole agent without signing up for anything. If you are
already logged in, it also creates the remote workspace (or uses yours, if it
exists).

The account turns up at `vatio push`, which is the first thing that makes
something run on a server. There the CLI opens your browser on Vatio's
sign-in page for this terminal (or prints the link, on a machine without a
browser). Sign in with your email — we mail you a link that creates your
account if it did not exist — or with Google or GitHub, check that the page
shows the code your terminal printed, and confirm. That authorizes this
terminal and takes you to the inbox at `inbox.vatio.ai`, whose **Start** page
waits for the push; **Test** is where you then try it. If the wait is cut off,
run `vatio push` again: it picks up the same sign-in page instead of starting
over.

#### Define the agent

Replace the starter contents of `vatio.yml` with:

```yaml
workspace: acme
agent:
  name: Acme Support
  instructions: |
    Help visitors understand Acme's warehouse robotics.
    Ask what they need help with. If you do not have the information
    to answer, say so. Never invent product details or prices.
  personality: Warm, concise, and clear.
```

`workspace` selects the remote workspace. `agent.instructions` defines what
its single agent does. The other fields in this example are optional. The
business the agent works for — its name, description and opening hours — is set
by whoever runs it on the [Business](https://vatio.ai/docs/channels/inbox#business) page of the
inbox, and the widget's look in **Channels → Web widget** in the inbox, not in
this file.

#### Deploy and test

```bash
vatio push
vatio chat "What does Acme do?"
```

`push` validates your files and deploys to `preview`. Open the link it prints
after `Try it:`, the inbox's **Test** page, to chat on the web, WhatsApp or
Instagram, or continue the conversation with another `chat` command.

```bash
vatio chat "Can you tell me the price?"
vatio chat show
vatio chat --new "Hi, who am I talking to?"
```

Check that the agent answers from the supplied context and acknowledges what
it does not know. `chat show` reads the current CLI chat with every tool call; `--new` starts a
new one as a visitor the agent never met.

#### Publish

When the preview is ready for customers:

```bash
vatio publish
```

Live is what answers customers through the [widget](https://vatio.ai/docs/channels/widget) on your
site, [WhatsApp](https://vatio.ai/docs/channels/whatsapp), [Instagram](https://vatio.ai/docs/channels/instagram) or the
[SDKs](https://vatio.ai/docs/sdk/). `vatio rollback` restores the previous live deployment.

Next, [add knowledge](https://vatio.ai/docs/knowledge), [connect your API](https://vatio.ai/docs/tools), or
[embed the widget](https://vatio.ai/docs/channels/widget).

### Teams

`https://vatio.ai/docs/team`

A workspace has one owner, whoever created it, and any number of members. The
owner invites them from **Team** in the inbox (under **Settings** in the rail),
by email, as one of three roles:

| | Owner | Developer | Supervisor | Viewer |
|---|---|---|---|---|
| [Inbox](https://vatio.ai/docs/channels/inbox): read every conversation, contact and chart | Yes | Yes | Yes | Yes |
| Inbox: take a conversation over, reply, flag replies, edit the business and its knowledge | Yes | Yes | Yes | No |
| Inbox's Test, Channels, Tools and Outreach; CLI and API: push, publish, secrets, tools, preview and live | Yes | Yes | No | No |
| The [daily mail](https://vatio.ai/docs/improve#the-daily-digest) with the open flags | Yes | Yes | No | No |
| Inbox's Team and Security: invite and remove members, security settings, delete the workspace | Yes | No | No | No |

There is nothing to accept. The invitation creates the account if there was
none, and the person signs in with that same email: a code by email, or Google
or GitHub with that address. **Team** shows them as pending until they first
sign in. The owner can change a member's role on the same page at any time.
Removing someone, or changing their role, takes effect on their next request.

The inbox shows each person the sections of their role. A supervisor or a
viewer has **Conversations**, **Contacts**, **Improvement**, **Analytics**,
**Knowledge** and **Business**. A developer also has **Test**, **Channels**,
**Tools** and **Outreach**, and the owner **Team** and **Security** on top.
Everyone has **Account**, their own settings, at the foot of the rail.

#### Limiting someone to some agents

A workspace with more than one agent can narrow a supervisor or a viewer to
the conversations of some of them: pick the agents next to their role on
**Team**. They then see only those agents' conversations, contacts, charts and
knowledge, in every environment. Leaving every agent unticked means all of
them. A developer always sees every conversation, because the CLI and the API
already reach all of it.

#### Activity

**Activity** (owner only, on the inbox's **Security** page) records who did
what in the workspace, kept for one year:

- **Sign-ins and sessions** of the owner and every member: sign-ins, failed
  attempts, signing out, closing a session, two-step verification changes.
- **Conversations**: opening one in the inbox (once per person and conversation
  every 15 minutes), searching (the number of results, never what was searched
  for), opening a contact, replying, taking a conversation over and handing it
  back, flagging a reply.
- **Team**: invitations, removals, role changes, and who opened the workspace.
- **Keys and secrets**: secrets set or deleted (by key, never the value),
  publishable tokens, server keys, webhooks.
- **Changes**: pushes, publishes, rollbacks, environments removed, business
  details, knowledge that reached live, security settings.
- **Data**: contacts exported or deleted, conversations removed by retention,
  Vatio support access opened or closed, and every time Vatio staff read
  conversations or contact details under it.

Each row says who, from which IP address and browser, and when. Nothing in it
holds conversation text or a credential.

#### Security

Every workspace is treated as holding sensitive data, with nothing to turn on:
conversations only go to AI models with zero data retention
([Models and data](https://vatio.ai/docs/model)), conversation text, phone numbers and emails are
encrypted at rest, everything the team does is recorded in **Activity**, and
Vatio staff cannot read conversations or contact details unless the owner
grants them support access.

**Security** in the inbox (owner only) holds two settings, next to
[Activity](#activity), [Vatio support access](#vatio-support-access) and
deleting the workspace.

**Require two-step verification.** Everyone on the team, the owner included,
then needs [two-step verification](#two-step-verification-and-sessions) to get
in, and everyone's sessions close after 12 hours without use. The owner has to
have it on their own account before turning this on. Until a workspace
requires it, its owner sees a banner on that workspace's pages recommending it.

**Retention.** How long a conversation is kept after its last message: until
someone deletes it, or 30, 90, 180 or 365 days. Past that, the conversation is
deleted with its messages, files and tool calls, and a contact left with no
conversation is deleted too.

#### Vatio support access

Vatio staff cannot open your workspace or read its conversations and contact
details. When you want Vatio to look into something, the owner opens
**Security → Vatio support access** in the inbox and gives access for
24 hours, 72 hours or 7 days. While it is open, a Vatio admin can use the inbox
and Vatio's own debugging tools on the workspace; it closes on its own, or at
once with **Close access now**.

If something is broken and you cannot be reached, a Vatio admin can open an
emergency access for up to 24 hours. It needs a written reason, and the owner
gets an email right away with who opened it, why and until when, with a link
to close it.

Both show on the inbox's **Security** page while they last, and every
opening, closing and read of a conversation under them is in **Activity**.

#### When a developer joins

The invitation email says what Vatio is and links to the workspace's
**Start** page in the inbox (`inbox.vatio.ai/<workspace>/start`). It has three
things, written for that workspace:

1. **Where the agent's files are:** a note to ask you for the repository.
   Vatio keeps what was deployed, not the files it came from, so a developer
   without the repository cannot work on the agent. Give them access to it.
2. **A prompt for their coding agent.** Pasted into Claude Code, Cursor or
   Codex in that folder, it installs the CLI, signs them in with the invited
   email, checks the folder against live and explains the agent, following
   [Joining an existing workspace](https://vatio.ai/docs/#joining-an-existing-workspace).
3. **The same steps by hand**, for a terminal.

By hand, it looks like this:

```bash
git clone https://github.com/acme/backend.git
cd backend                  # the folder whose vatio.yml says workspace: acme
npm install -g @vatio-ai/cli@latest
vatio login                 # with the email the invitation went to
vatio diff --env live       # what this folder would change if published
vatio push                  # updates preview
```

Signing in with any other address is a different account, and every command
answers `workspace_forbidden`.

Preview is one per workspace, shared by everyone in it. Once the agent is
published, a push from another git branch is refused while preview holds a
version nobody has published yet, so no one replaces someone else's work
without knowing ([Working with others](https://vatio.ai/docs/deployments#working-with-others)).
Live changes with `vatio publish`, or **Publish** on the inbox's **Test**
page, once preview's tests allow it ([Publishing](https://vatio.ai/docs/deployments#publishing)).

#### When a supervisor or a viewer joins

Their email links straight to the inbox, at `inbox.vatio.ai/<workspace>`.
They see conversations, contacts, knowledge, the business and what their flags
became, and nothing else: no deploy, no tools. A viewer reads the same pages with no reply box and
nothing to edit. See [Inbox](https://vatio.ai/docs/channels/inbox).

#### Two-step verification and sessions

Anyone can turn on two-step verification in **Account**, at the foot of the
inbox's rail. After the emailed code, Google or GitHub, Vatio then asks for one
more thing:

- **A passkey**: a phone, a laptop's fingerprint reader or a security key. One
  passkey works on the inbox and on the sign-in at vatio.ai.
- **A code from an authenticator app**, such as Google Authenticator,
  1Password or Authy.
- **A recovery code**, one of ten shown once when the first method is added,
  for when the other two are out of reach.

Until it is on, every page of the inbox carries a banner recommending it; it
goes away the moment the account has it.

A workspace whose owner requires it (**Security**, owner only) asks it of
everyone on its team, the owner included: the inbox asks anyone without it to
turn it on in **Account**, and the CLI answers `two_factor_required`.
Their sessions also close after 12 hours without use.

**Account** also lists where you are signed in and closes any of it: each
browser signed in at vatio.ai, each inbox device and the CLI's token. A
vatio.ai browser signs out after 7 days without use and 30 days at most; an
inbox device after 90 days without use; a CLI token after 90 days without use,
and `vatio logout` revokes it on the spot.

Deleting a workspace, inviting or removing a member, minting a server key and
changing a workspace's security settings ask you to confirm it's you again if
you last did so more than 10 minutes ago.

### Workspace and manifest

`https://vatio.ai/docs/manifest`

A workspace contains an agent and its conversations, contacts, knowledge,
secrets, and channel connections. Its deployable configuration lives in a
directory containing `vatio.yml`:

```text
support-agent/
  vatio.yml          # required
  tools/*.yml        # tools
  identity.pub       # public key that verifies your JWT (see Authentication)
```

Only create the files you need. The CLI finds the nearest `vatio.yml` at or
above the current directory and reads `workspace` to select the remote.
Deployment commands have no `--workspace` flag or `VATIO_WORKSPACE` override.
Your user credentials live separately in `~/.vatio/config.json`.

The guides below show configuration fragments. Merge them into your existing
manifest, keeping one copy of each root key and preserving the instructions
and tool assignments you still need.

#### Root keys

These are the supported root keys. Unknown root keys fail validation.

| Key | Purpose |
|---|---|
| `workspace` | Remote slug: lowercase letters and digits separated by hyphens |
| `agent` | The agent definition; requires non-empty `instructions` |
| `auth` | Public key that verifies your JWT, or your Supabase project; see [Authentication](https://vatio.ai/docs/authentication/) and [Supabase](https://vatio.ai/docs/integrations/supabase) |

`business`, `widget` and `allowed_origins` — and `hours` inside `agent:` — are
still accepted but ignored: the push succeeds and prints a warning with a link.
The business name, description and opening hours are set on the
[Business](https://vatio.ai/docs/channels/inbox#business) page of the inbox, the widget's look in
**Channels → Web widget** in the inbox, and each publishable token has its own
allowed origins, edited in that page's tokens section or with
[`vatio tokens`](https://vatio.ai/docs/cli/tokens). Remove the blocks when convenient.

`knowledge` and `links` are not root keys: the agent declares its own, inside
the `agent:` block. `identity` is `auth`. `agents` and `entry` are gone: a
workspace runs one agent, under `agent:`, and private tools behind `auth:`
cover what a second, signed-in agent was for. A manifest still carrying any of
these fails the push with the migration to make.

### Agents

`https://vatio.ai/docs/agents`

The `agent` block describes an agent:

| Field | Required | Purpose |
|---|---|---|
| `instructions` | Yes | Tasks, decision rules, and when to use tools |
| `name` | No | Display name; defaults to the agent's key |
| `personality` | No | Voice and tone; defaults to warm, brief, and clear |
| `tools` | No | Tool keys the agent may call; defaults to an empty list |
| `knowledge` | No | Knowledge bases this agent reads |
| `links` | No | Urls this agent may hand out |

There is no `agent.key` to configure. A workspace runs exactly one agent, and
the runtime calls it `main`. To give signed-in visitors more than anonymous
ones, keep the one agent and mark the tools that read their data
`access: private`: those run only for a visitor verified through
[`auth:`](https://vatio.ai/docs/authentication/).

```yaml
agent:
  instructions: |
    Answer product questions using knowledge_lookup.
    If the knowledge does not answer the question, say what is missing.
  tools: [knowledge_lookup]
  knowledge: [docs]
```

`knowledge:` and `links:` belong to the agent, not to the workspace — the agent
states what it reads and which urls it may hand out.

Create the `docs` knowledge base with `vatio kb create docs` before pushing this
example: a referenced base must already exist.

#### Business context

The business name, description and [opening hours](https://vatio.ai/docs/handoff#business-hours)
are not part of the manifest. Whoever runs the business sets them on the
**Business** page of [the inbox](https://vatio.ai/docs/channels/inbox). The name accepts up to 100
characters and the description 2,000.
They apply to every environment as soon as they are saved; a `business:` block
or an `hours:` block in `vatio.yml` is ignored with a warning.

Write instructions about your product and workflow. Vatio adds business
context, contact state, tool descriptions, and its platform instructions.
The agent normally replies in the visitor's language; specify a language in
`instructions` or `personality` when your product requires one.

### Tools

`https://vatio.ai/docs/tools`

Tools let the agent call your APIs. A tool is one HTTP request, described in
YAML. The filename is the tool key: `tools/check_stock.yml` becomes
`check_stock`.

Anything that needs branching, a second call, or a derived response is code,
and it belongs in the backend the tool already calls — give the agent one
endpoint that does the whole job.

Add each tool to `agent.tools`; creating its file alone does not enable it.

A tool runs inside the agent's turn and answers in seconds. Work that takes
minutes and changes your code goes to a Claude Code routine, which the agent
also gets as a tool: see [Routine tools](#routine-tools).

#### Declarative HTTP tools

Set your backend URL as a workspace secret:

```bash
vatio secrets set ACME_API https://api.acme.com
```

Create `tools/check_stock.yml`. This example expects the API to return
`{"data": [{"name": "Picker", "stock": 3}]}`:

```yaml
description: Check product stock.
when_to_use: The visitor asks whether a product is available.
parameters:
  type: object
  properties:
    product: { type: string }
  required: [product]
access: public
request:
  method: GET
  base_url: "$env.ACME_API"
  path: /products
  requires: [params.product]
  query:
    name: "$params.product"
respond:
  data:
    products: "$.data"
  message:
    when_empty: No products matched that name.
    default: "Found {{count}} matching products."
```

Merge this into your existing `agent` block:

```yaml
agent:
  instructions: Use check_stock to answer questions about product availability.
  tools: [check_stock]
```

The request supports `GET`, `POST`, `PATCH`, and `DELETE`. Supply `method`,
`base_url`, and `path`; the base URL must include its scheme and host, and can
include a path of its own. `path` is appended to it: a `base_url` of
`https://<ref>.supabase.co/functions/v1` and a `path` of `/my-tools/check_stock`
call `https://<ref>.supabase.co/functions/v1/my-tools/check_stock`. A `path`
that already starts with the base URL's path is not prefixed twice.
`headers` and `query` are maps; `body` is a JSON map used for `POST` and `PATCH`.

##### Placeholders

Values in `base_url`, `path`, `headers`, `query`, and `body` support:

| Placeholder | Source |
|---|---|
| `$params.<name>` | Tool arguments |
| `$env.<KEY>` | Workspace secrets |
| `$auth.subject`, `$auth.token`, `$auth.claims.<key>` | The tool's authenticated principal |
| `$contact.name`, `$contact.email`, `$contact.phone_number` | Contact profile |

A single placeholder keeps its value's type. A placeholder within a string
is interpolated. `||` selects the first non-empty alternative, such as
`"$params.name || $contact.name"`.

##### Required values

Missing values are omitted from the request. List any value the endpoint needs
under `request.requires`, without the `$` prefix, to fail before sending an
incomplete request. A missing contact phone, email, or name produces
`missing_contact_phone`, `missing_contact_email`, or `missing_contact_name`;
other paths produce `missing_requirement`.

On WhatsApp, a tool can count on `$contact.phone_number`. It comes with the
message; a visitor who hides their number behind a WhatsApp username is sent a
message with a one-tap button to share it the first time a tool that requires
`contact.phone_number` runs, and that message is the agent's reply for the
turn. The number arrives with their next message, and the tool runs then. You
do not need `request_contact_info` for this.

Anywhere else, `$contact.phone_number` comes only from the visitor's
[sign-in](https://vatio.ai/docs/authentication/): the `phone_number` claim of your identity token,
or a Supabase user's phone. A number the visitor types in the chat is never
used, so nobody can pass for someone else by typing theirs. On
`missing_contact_phone` there, the agent is told not to ask for the number and
to say this cannot be done from that chat.

##### Response mapping

`respond.data` maps output keys to JSON paths: `$` is the entire body, `$.data`
is its `data` field. `respond.message` can be a literal string or the
`when_empty` / `default` mapping shown above. `{{count}}` is
the length of the first array in the mapped data. Without an array, `when_empty`
applies when all mapped values are blank.

Omit `respond.message` to use the API's own `message` field. HTTP 2xx produces
`result: "ok"`; non-2xx produces `result: "error"`, so return the status that
says what happened rather than encoding failures inside a 200.

On a non-2xx, the agent gets the body's `message` and `error_key`, and every
other top-level field as it is, such as a `hint`. A body with no `message` but
a string `error` (`{ "error": "...", "hint": "..." }`) uses `error` as the
message. With neither, the message only gives the HTTP status. A body with no
`error_key` gets `unexpected_http_status`.

#### Tool results

Every tool returns an object with `result` (`ok` or `error`) and a non-empty
`message`. Additional fields, such as `data` and `error_key`, are allowed.

```json
{ "result": "ok", "message": "This product is out of stock.", "data": { "stock": 0 } }
```

```json
{ "result": "error", "message": "Stock could not be checked.", "error_key": "backend_unavailable" }
```

Use `ok` when the operation completed, including an empty result or negative
answer. Use `error` when it could not complete. Invalid results become a
platform error. Tool results are visible to the model; never return credentials.

One reply can make up to 12 tool calls. After the twelfth, or after the same
tool fails three times with the same `message`, the agent is told to stop and
answer with what it has, saying what it could not do, and any further call in
that reply is refused. So make an error's `message` say what to change:
"`account` cannot be changed; delete the transaction and create it again" lets
the agent recover on its next call, where "Nothing to change" only gets it
retrying.

Validate with `vatio diff`, deploy with `vatio push`, and exercise the
tool through `vatio chat`. There is no direct tool-invoke command.

#### How your backend is answering

Every call a declarative tool makes is measured where it leaves Vatio: the
HTTP status, how long your backend took, and how it ended — `ok`, an HTTP
error, a timeout (8 seconds), a connection that failed, or a 2xx that was not
JSON. A failing tool does not always show in the conversation, because the
agent apologizes and moves on, so check the inbox's **Tools** page:

- each tool's calls, error rate, timeouts, and p50/p95 latency over the last
  hour, day, 7 days or 30 days, per environment, including tools never called;
- the calls that failed, with what your backend answered and a link to the
  conversation.

Your coding agent reads the same page with `vatio tools`: markdown that
starts with how to work it, or `--json`. See
[Tool metrics](https://vatio.ai/docs/cli/improve#tool-metrics).

In the inbox, each of the agent's replies shows the calls it made, with their
arguments and what came back. What your backend answered is kept encrypted, cut to 4 KB, and deleted
after 30 days; the status, duration and outcome stay.

#### Routine tools

A tool file can name a [Claude Code routine](https://vatio.ai/docs/integrations/claude-routines)
instead of a request. The agent calls it when someone it may serve asks for
work on your code, and Claude takes the conversation from there:

```yaml
### tools/encargos.yml
description: Pass a request to Claude, which reviews or changes the code of our apps in a separate session.
when_to_use: Someone asks to review or change something in our apps and you know what it is. If what to change or where is missing, ask first.
routine: encargos
```

- **`routine:`** names a routine from `vatio routines list`, in the same
  environment.
- **No `request:`, `access:` or `parameters:`.** The routine reads the
  conversation itself, and its numbers say who may use it.
- **Offered only where it may run:** on WhatsApp, in conversations with the
  routine's numbers, while the routine is on. Everyone else's conversations do
  not have it.
- **`when_to_use` is what the agent decides by.** Say when the request is
  complete enough to hand over, so the agent asks first when it is not.

#### Built-in tools

| Tool | Purpose |
|---|---|
| `knowledge_lookup` | Search the knowledge bases listed in `knowledge` |
| `identify_contact` | Save a name supplied by the visitor |
| `request_contact_info` | Ask a WhatsApp visitor to share their phone number |
| `remember` | Save a lasting fact about the contact for later conversations |
| `recall` | Search summaries of earlier conversations with the contact |
| `handoff` | Hand the conversation to a person — see [Human in the loop](https://vatio.ai/docs/handoff) |

Enable `knowledge_lookup`, `identify_contact` and `request_contact_info`
through `agent.tools`, just like your own tools.
`handoff` is attached by declaring `agent.handoff`, so an agent cannot escalate
without a configured destination.
`request_contact_info` sends a message with a sharing button, and that message
ends the agent's turn; the number arrives only if the visitor shares it in a
later message, and the dependent operation can be retried then. A tool that requires `contact.phone_number` sends
the same request on its own (see [Required values](#required-values)), and the
two never send it twice in a row.

##### Memory

Once a conversation with a known contact has been quiet for 30 minutes, Vatio
summarizes it in two or three lines and saves any lasting facts it revealed as
notes, for every agent. Supervisors read both in the inbox, on the contact.

Enable `remember`, `recall` or both, the same way, to give the agent that
memory too. With either one on:

- Up to 20 notes about the contact are in the agent's prompt on every
  conversation, so it knows them without calling anything.
- `remember` saves a note the moment the visitor says something worth keeping.
- `recall` searches the summaries and notes, and returns the few that match --
  never a whole transcript.

Memory belongs to the contact, so every agent in the workspace that has it on
shares it, and preview and live contacts never see each other's.
If your workspace already defines a tool named `remember` or `recall`, yours
keeps the name and the platform tool is not attached.

A tool that reads a signed-in visitor's own records needs
[`access: private`](https://vatio.ai/docs/authentication/tools) and an `auth:` block.

### Knowledge bases

`https://vatio.ai/docs/knowledge`

A knowledge base holds **entries**, and an entry is Markdown you write — from
the CLI or in the inbox. Vatio does not read websites: if the answer lives on a
page, put what the agent needs in an entry.

```bash
vatio kb create docs
vatio kb write docs horarios horarios.md
vatio kb show docs
vatio kb publish docs                      # take what you wrote live
```

Create one base per set of entries you want kept apart.

Reference the base on the agent that reads it, and give it `knowledge_lookup`:

```yaml
agent:
  instructions: |
    Search knowledge_lookup before answering questions about Acme's policies.
    If the results do not answer the question, say so.
  tools: [knowledge_lookup]
  knowledge: [docs]
```

Run `vatio push` after changing these references. A referenced base must
already exist — a misspelled name fails the push rather than quietly becoming
an empty base. Removing a reference does not delete its content.

#### Entries

```bash
vatio kb write docs refunds refunds.md   # creates it, or replaces what is there
vatio kb cat docs refunds                # exactly what Vatio holds
vatio kb cat docs refunds | edit | vatio kb write docs refunds
```

`vatio kb write BASE ENTRY [FILE]` puts Markdown under a name, reading stdin
when no file is given. The entry's title comes from its own `# heading`.
Headings divide an entry into searchable sections; write each one so it can
answer a question on its own.

Use `vatio kb show docs` to check which entries are ready to answer from.
Indexing is asynchronous: an entry shows as ready once its sections have been
embedded. Test retrieval in a conversation afterwards.

`vatio kb rm-entry BASE ENTRY` deletes one entry. `vatio kb rm BASE` deletes a
base only when no deployed agent references it.

#### Preview and live

The entries are what **live** answers from. An edit in the inbox changes live
directly, and every change is kept as a numbered version of its entry, with who
made it and from where. The inbox is where supervisors and developers edit
entries and upload a file into an entry; the CLI does all of it too.

**Preview** keeps its own changes on top of live and reads everything else
from it. `vatio kb write`, `cat`, `rm-entry`, `show` and `status` work in
preview, so a change is written there, tried there, and goes live when preview
does:

```bash
vatio kb write docs horarios horarios.md   # only preview answers with it
vatio kb status docs                       # what live does not have yet
vatio publish                              # the agent and these entries, together
```

`vatio kb publish docs` takes one base's changes live on their own, without
the agent. `--env live` reads and writes live directly.

##### Conflicts

When live changes an entry after preview changed it too — a supervisor fixed
it in the inbox, say — publishing preview would overwrite a change nobody
working on preview has read. So it is refused, and `vatio kb status` names the
entry. Read it from live, write your change on top, and the conflict clears:

```bash
vatio kb cat docs horarios --env live > horarios.md
$EDITOR horarios.md
vatio kb write docs horarios horarios.md
```

Or resolve it side by side in the inbox: under **Test**, preview has a page
with the diff of every entry it changed (`vatio env` prints its link). For a conflict it shows
what live changed and what preview changed, and offers three ways out: keep
preview's version on top of live, take live's, or combine both by hand.

##### History

`vatio kb history docs` lists every version that reached live, newest first.
Going back is writing an old version again, which becomes the newest:

```bash
vatio kb cat docs horarios --version 3 > horarios.md
vatio kb write docs horarios horarios.md --env live
```

The inbox does this with one button on the entry's history.

### Links

`https://vatio.ai/docs/links`

Declare static destinations on the agent that may hand them out:

```yaml
agent:
  instructions: …
  links:
    help: https://acme.com/help
    product:
      url: https://acme.com/products/{category}
      when: The visitor wants to browse a product category.
      values:
        category: [picking, packing, sorting]
```

A value can be a URL string or a mapping with `url`, optional `when`, and
`values` for every placeholder. URLs must be absolute HTTP or HTTPS URLs.
Placeholder values use letters, digits, `.`, `_`, `~`, or `-`.

Vatio expands every combination at deploy time, with a limit of 100 URLs.
Use a tool for dynamic destinations or larger catalogs. Links returned by a
tool are allowed for the rest of that conversation.

A URL written straight into `instructions` is shareable too, so a one-off
mention needs no entry here. `links` remains the better place for a
destination the agent should actually offer: it carries a `when` that tells
the agent when to reach for it, and it expands placeholders.

Which URLs a reply may carry at all is enforced by the
[safeguards](https://vatio.ai/docs/safeguards).

### Human in the loop

`https://vatio.ai/docs/handoff`

Two things decide when a person takes over: the agent's `handoff` block, which
you deploy, and the business hours, which whoever runs the inbox sets.

```yaml
agent:
  instructions: ...
  handoff:
    after_turns: 8
    when:
      - el cliente pide hablar con una persona
      - reclamo por un cobro
```

Declaring `handoff` is what gives the agent the tool to hand a conversation
over. Calling it marks the chat as waiting for a person; it shows up that way
in [the inbox](https://vatio.ai/docs/channels/inbox), and whoever replies there or from the WhatsApp
Business app takes it over. Vatio does not notify anyone yet.

The agent says so plainly. It is an assistant, it does not pretend otherwise,
and when it hands something over it tells the visitor it is checking with a
colleague and will come back with an answer — no invented errand to cover for
it. Several jurisdictions require disclosing that a person is talking to a
machine, and a single honest behavior is one less thing to get wrong.

#### When it escalates

**`handoff.when`** is your own list of conditions, in your words, and goes into
the prompt. **`handoff.after_turns`** is the deterministic floor under it: past
that many visitor messages the handoff happens whether or not the model asked
for one. A visitor who asks for a human in plain words always triggers one —
that never depends on the model's judgement. Declare neither and escalation is
entirely the model's judgement.

#### Business hours

The hours are not in `vatio.yml`. They belong to the people who answer, so
they are set on the **Business** page of [the inbox](https://vatio.ai/docs/channels/inbox): a time
zone and one or more shifts per day. A developer setting a workspace up can use
[`vatio business hours`](https://vatio.ai/docs/cli/business) instead. They apply to every
environment as soon as they are saved, and an `hours:` block left in
`vatio.yml` is ignored with a warning.

The agent is given the week, so it can tell a customer when you are open. The
hours do **not** decide who answers — the agent triages and tries to resolve
either way. They decide what happens after it gives up:

| | Inside hours | Outside hours |
|---|---|---|
| The agent | Goes quiet, someone is there to pick it up | Keeps answering — it promised a person for the morning, and going silent on top of that is worse than helping |

With no hours set the agent is treated as always reachable, so a handoff
always quiets it. A handoff nobody picks up within an hour hands the
conversation back to the agent rather than leaving the visitor with a promise
and silence.

### Safeguards

`https://vatio.ai/docs/safeguards`

Vatio checks draft replies for empty content, generated media, and unauthorized
links. A failed check triggers a correction attempt. These checks run for every
agent and cannot be disabled through the manifest.

Replies are text. The agent can read supported inbound media but cannot generate
or send files. Web replies use Markdown; WhatsApp and Instagram use plain text.

A reply may only carry a URL the agent was already given: one declared in
[`links`](https://vatio.ai/docs/links), one written anywhere else that reaches the prompt
(`instructions`, `personality`, the business description, a tool's
`when_to_use`), or one a tool returned in the conversation. It must be copied
exactly; a URL the agent assembled or altered is stripped. Nothing a visitor
typed becomes allowed.

### Models and data

`https://vatio.ai/docs/model`

Vatio runs the model for you: there is no model to host and no model key in
`vatio.yml`. Every request to a model goes through
[OpenRouter](https://openrouter.ai), and only to endpoints with zero data
retention.

#### Which models

Every agent uses the same models. Vatio picks them and may change them, and
this page always lists the current ones.

| Job | Model |
|---|---|
| Replying to visitors | DeepSeek V4.1 Flash (`deepseek/deepseek-v4.1-flash`) |
| [Memory](https://vatio.ai/docs/tools#memory) summaries and the question behind [`chat.rate`](https://vatio.ai/docs/sdk/reference) | The same model as the replies |
| Deciding when to ask for that rating | Jev, by Typesafe (`typesafe/jev-1.13`) |
| Transcribing voice notes | Voxtral Small, by Mistral (`mistralai/voxtral-small-24b-2507`) |
| Watching videos and reading PDFs | Gemini 3.8 Flash (`google/gemini-3.8-flash`) |
| Searching [knowledge](https://vatio.ai/docs/knowledge) | OpenAI `text-embedding-3-small` |
| Judging [evals](https://vatio.ai/docs/improve) | Gemini 3.8 Flash: a different family than the replies, so it never grades its own kind of answer |

DeepSeek's models are open-weight. OpenRouter serves them from independent
inference providers, not from DeepSeek's own API.

How long conversations stay in Vatio, and how to delete them, is in
[Retention and deletion](https://vatio.ai/docs/data).

#### Zero data retention

Every request, replies and embeddings alike, asks OpenRouter for
[zero data retention](https://openrouter.ai/docs/guides/features/zdr)
endpoints only. The provider that runs the model keeps neither the prompt nor
the answer, and trains on neither. When no such endpoint is available the
request fails rather than going to one that keeps data: Vatio retries it, and
if it still fails the visitor gets the same message as for any reply that
could not be generated.

Vatio keeps the conversation itself, to run the inbox, memory and evals. The
[privacy policy](https://vatio.ai/legal/privacy-policy) lists every
sub-processor and where it runs.

#### Using a different model

Choosing the model for an agent is not available yet. If yours needs a
specific one, such as a larger model for harder conversations or one your
company already approved, write to [support@vatio.ai](mailto:support@vatio.ai)
with the model and what it is for.

### Retention and deletion

`https://vatio.ai/docs/data`

What your agent's conversations leave behind, how long it stays, and how to
answer a person who asks for their data or asks to be forgotten.

#### What is encrypted

Conversation text, contacts' phone numbers and emails, what the agent
[remembers](https://vatio.ai/docs/tools#memory) about a person, tool-call arguments, your backend's
answers to them, reported replies and the eval cases made from them are
encrypted at rest. Delivery records from WhatsApp and Instagram keep which
message it was and when, not what it said or who sent it.

Every model call goes to an endpoint with zero data retention: see
[Models and data](https://vatio.ai/docs/model).

#### Who at Vatio can see it

Nobody, by default. Vatio staff see a workspace's conversations and contact
details only while its owner has given support access, or during an emergency
access an admin opened with a reason, which emails the owner at once. Both
expire on their own and every read under them is recorded in the workspace's
**Activity**. See [Teams](https://vatio.ai/docs/team#vatio-support-access).

#### How long it stays

| What | How long |
|---|---|
| Conversations, messages and files | Until you delete them, or your workspace's retention period runs out |
| Your backend's answers to tool calls | 30 days |
| Access logs | 90 days |
| The workspace's **Activity** (who did what) | 1 year |
| A deleted workspace | 30 days, then everything in it is purged |

#### Set a retention period

In the inbox, the owner opens **Security** and chooses how long to keep
conversations: 30, 90, 180 or 365 days, or until someone deletes them. Every night, a conversation with no new message for
longer than that is deleted with its messages, files, tool calls and channel
deliveries. A contact left with no conversation is deleted too, with what the
agent remembered about them. Each run is recorded in the workspace's
**Activity** with how much it deleted.

Every workspace is treated as holding sensitive data: conversations and
contact details stay out of Vatio's own support tools unless the owner grants
support access. The same page lets the owner require
[two-step verification](https://vatio.ai/docs/team#two-step-verification-and-sessions) from the
whole team, which also closes everyone's sessions after 12 hours without use.

#### Export or delete one person

When someone asks for what you keep about them, or asks to be forgotten:

- **From the inbox.** Open a conversation with them, open the contact pane,
  and use **Download their data** or **Delete this contact and their data**.
  Deleting asks you to type a word first. Only the owner and developer members
  see these; supervisors do not.
- **From your backend.** Call the [Contacts API](https://vatio.ai/docs/api/contacts).

The export is one JSON file: the contact, the channels they wrote from, what
the agent remembers about them, and every conversation with its messages.

Deleting removes the contact and their conversations, messages, files, tool
calls, channel deliveries, memories, outreaches, and the eval cases made from
their replies with those cases' replays. It cannot be undone. Both are
recorded in **Activity**, without the person's name.

### Authentication

`https://vatio.ai/docs/authentication/`

**You sign a JWT saying who the visitor is. Vatio verifies it and passes it to
your tools.** That is the whole of authentication — there is one mechanism, on
every channel.

Vatio holds only your **public** key, so it can check a token but never mint
one. Your backend stays the only thing that can say who someone is.

::: tip Users sign in with Supabase?
Skip the keypair and the signing: `vatio auth --supabase` names your project,
and the widget, WhatsApp and Instagram use the Supabase session as it is. See
[Supabase](https://vatio.ai/docs/integrations/supabase).
:::

#### Set it up

Generate the keypair:

```bash
vatio auth --new-key
```

That writes two files, and they go to different places.

**`identity.pub` stays in the workspace.** It is a public key, so it is
committed like any other file and `vatio.yml` names it:

```yaml
### vatio.yml
auth:
  public_key: identity.pub
```

**`identity.pem` goes into your own backend**, as a secret — an environment
variable, or whatever credential store you already use. Your backend is what
signs, so it is the only thing that ever needs it. Load it, then delete the
file from the workspace directory; it has no job there.

```bash
### environment variable
VATIO_IDENTITY_PRIVATE_KEY="$(cat identity.pem)"

### or Rails credentials
bin/rails credentials:edit     # vatio: { identity_private_key: "..." }
```

**Never `vatio secrets set` the private key.** That store is read by Vatio, to
let your tools call your API. A private key in there would let Vatio *mint*
tokens for your users instead of only checking them, which is the one thing
this design exists to prevent. Vatio holds the public half and nothing else.

Mark every tool that needs a signed-in user:

```yaml
### tools/my_bookings.yml
access: private
```

Tools without `access: private` are public and run for anyone.

#### The token

Sign it with `identity.pem` using **RS256**:

| Claim | Required | Value |
|---|---|---|
| `sub` | yes | Your user id. Becomes `$auth.subject`. |
| `aud` | yes | Your workspace slug, exactly. |
| `exp` | yes | Unix seconds. Keep it short — Vatio re-checks it on every tool call. |
| `name` | no | Fills the contact's name. |
| `email` | no | Fills the contact's email. |
| `phone_number` | no | Fills the contact's phone. |
| `metadata` | no | What the inbox shows about the person: a `profile` link and `badges`. See [Enrich the inbox](#enrich-the-inbox). |

Any other claim you add arrives as `$auth.claims.<name>`.

```ruby
### Ruby
JWT.encode(
  { sub: user.id.to_s, aud: "acme", exp: 1.hour.from_now.to_i,
    name: user.name, email: user.email },
  OpenSSL::PKey::RSA.new(ENV["VATIO_IDENTITY_PRIVATE_KEY"]), "RS256"
)
```

```js
// Node (jsonwebtoken)
jwt.sign(
  { sub: String(user.id), name: user.name, email: user.email },
  process.env.VATIO_IDENTITY_PRIVATE_KEY,
  { algorithm: "RS256", audience: "acme", expiresIn: "1h" }
);
```

```python
### Python (PyJWT)
jwt.encode(
    {"sub": str(user.id), "aud": "acme",
     "exp": int(time.time()) + 3600, "name": user.name},
    os.environ["VATIO_IDENTITY_PRIVATE_KEY"], algorithm="RS256",
)
```

`aud` is required because a signature proves *who* signed, not *what for*.
Without it, any other JWT you sign with the same key — a password reset, a
download link — would be accepted here as an identity.

#### Enrich the inbox

Put a `metadata` object in the token and the inbox shows it in the
conversation header, so whoever answers knows who they are talking to without
opening your admin:

```json
{
  "sub": "42",
  "aud": "acme",
  "exp": 1790000000,
  "name": "Claudio Andrés",
  "metadata": {
    "profile": "https://acme.com/admin/users/42",
    "badges": {
      "plan": { "value": "Premium", "tone": "green" },
      "payment": { "value": "Overdue", "tone": "red" },
      "country": "CL"
    }
  }
}
```

| Key | Type | Shown as |
|---|---|---|
| `profile` | string, `http://` or `https://` URL | A **Profile ↗** link that opens in a new tab. Anything else shows a quiet "Invalid profile link" warning instead, with the value you sent in its tooltip. |
| `badges` | object of label → value | One chip per entry, `plan: Premium`, in the order you send them. A value is a string, number or boolean, or `{ "value": ..., "tone": ... }` to colour the chip. Up to 10, each label and value cut at 60 characters. |

`tone` is one of `green`, `amber`, `red` or `gray`. Leave it out, or send
anything else, and the chip takes your inbox's accent colour.

Both are optional, and any other key under `metadata` is ignored by the inbox.
The inbox reads it from the latest token for that conversation, so a
refreshed token updates the header. Tools see the whole object as
`$auth.claims.metadata`.

```js
// Node (jsonwebtoken)
jwt.sign(
  {
    sub: String(user.id),
    name: user.name,
    email: user.email,
    metadata: {
      profile: `https://acme.com/admin/users/${user.id}`,
      badges: {
        plan: { value: user.plan, tone: user.paying ? "green" : "gray" },
        country: user.country,
      },
    },
  },
  process.env.VATIO_IDENTITY_PRIVATE_KEY,
  { algorithm: "RS256", audience: "acme", expiresIn: "1h" }
);
```

Next: how the token reaches Vatio on
[each channel](https://vatio.ai/docs/authentication/sessions), and what a
[private tool](https://vatio.ai/docs/authentication/tools) sees once it does.

### Identity flows

`https://vatio.ai/docs/authentication/flows`

A known visitor is a JWT signed with **your** private key, verified by Vatio
with your public key, and forwarded to your API as `$auth.token` when a private
tool runs. It reaches Vatio on the web, where your page renders it onto the
widget, and on WhatsApp and Instagram, where the contact brings it back from
your sign-in page ([On WhatsApp and Instagram](https://vatio.ai/docs/authentication/sessions#on-whatsapp)).

The diagram below is [Mermaid](https://mermaid.js.org) so that they read the
same in a browser, in the [Markdown twin](https://vatio.ai/docs/authentication/flows.md) of this
page and to a coding agent.

#### Keys and credentials

| Credential | Signed by | Verified by | Lives | Used on |
|---|---|---|---|---|
| Identity JWT (`$auth.token`) | Your `identity.pem` | Vatio, with the `identity.pub` in `vatio.yml` | The `exp` you choose | Web |
| Publishable token (`vatpub_…`) | Issued by Vatio | Vatio, plus the token's allowed origins | Until revoked | Web |
| `chat_token` | Vatio | Vatio | 12 hours | Web |

`$auth.token` is always the identity JWT. The publishable token names the
workspace, not the person, and never reaches your backend.

#### Web

```mermaid
sequenceDiagram
  autonumber
  participant B as Your backend
  participant W as Widget / SDK
  participant V as Vatio
  participant A as Your API
  B->>W: page with data-token=vatpub_… and data-visitor-token=JWT
  W->>V: POST /api/visitor/v1/:ws/chats (Bearer vatpub_, visitor_token)
  V->>V: origin allowed for the token, rate limit, verify JWT with identity.pub
  V->>V: store the JWT, write name/email/phone_number onto the contact
  V-->>W: chat_token (12 h)
  W->>V: messages (Bearer chat_token)
  V->>V: private tool: verify the stored JWT again (exp)
  V->>A: Authorization: Bearer $auth.token
  A->>A: verify with your key
```

- A visitor who signs in mid-conversation: the page calls
  `VatioWidget.identify(jwt)`, and the SDK posts it to
  `/api/visitor/v1/:ws/chats/:id/identify`.
- A JWT with a different `sub` answers `identity_changed`, and the SDK starts a
  new conversation. One person never sees another's.
- No JWT means an anonymous chat. Private tools answer that they could not
  confirm who the visitor is.
- Vatio never mints a token: your backend signs it. See [On the web](https://vatio.ai/docs/authentication/sessions#on-the-web).

#### Where to go next

- [Sessions and channels](https://vatio.ai/docs/authentication/sessions): rendering the token, and
  signing in mid-conversation.
- [Private tools](https://vatio.ai/docs/authentication/tools): `access: private` and the `$auth.*`
  placeholders.
- [`vatio-sdk`](https://vatio.ai/docs/sdk/ruby):
  the Ruby gem that signs and verifies the JWT.

### Sessions and channels

`https://vatio.ai/docs/authentication/sessions`

#### On the web

Render the token onto the widget's script tag. Signed-out visitors simply get
no token, and the agent answers with its public tools.

```erb
<script src="https://cdn.vatio.ai/v1/widget.js"
        data-workspace="acme"
        data-token="vatpub_..."
        <%= "data-visitor-token=\"#{visitor_jwt}\"".html_safe if current_user %>></script>
```

To sign someone in without reloading the page:

```js
window.VatioWidget.identify(newToken);   // null to sign out
```

#### Signing in mid-conversation

A visitor asks something, the agent tells them to sign in, they do, and they
come back. **The conversation survives.** Whether they signed in through
`identify()` or by navigating away and reloading the page with a token, the
chat they already had is carried over and now has a name on it — so the agent
can answer the question that prompted the login without them retyping it.

| Before | After | What happens |
|---|---|---|
| anonymous | signed in | Same conversation, now identified |
| user A | user A, newer token | Same conversation, token refreshed |
| user A | user B | **New conversation** |
| signed in | signed out | New conversation |

The last two are the shared-laptop case: a conversation that belonged to one
`sub` is never handed to another, so signing in as someone else looks exactly
like arriving for the first time. Vatio enforces that server-side — the page
cannot opt out of it.

Refreshing is also how a web session stays alive: render a fresh token on each
page load and a long-running conversation keeps working past the first token's
`exp`.

#### On WhatsApp and Instagram {#on-whatsapp}

A WhatsApp or Instagram chat has no page to render a token onto, so the
contact is sent to one. Add `sign_in_url` to `auth:`:

```yaml
auth:
  public_key: identity.pub
  sign_in_url: https://acme.com/vatio/sign-in
```

When a private tool runs for a contact who is not signed in, Vatio sends them a
message with a **Sign in** button instead. That message is the agent's whole
reply for the turn, and the inbox shows it with the button under it:

1. The button opens `sign_in_url?code=...`. The code works once, for 10 minutes.
   If they write again without having signed in, the next private tool sends
   a new button.
2. Your page signs them in the way your site already does. If they are already
   signed in on that phone, there is nothing to type.
3. It redirects to `https://vatio.ai/connect/<code>?token=<jwt>`, with the same
   JWT you put on the widget.
4. Vatio verifies it, and the conversation picks up where it left off: the
   agent runs the tool it was waiting on.

With [vatio-sdk](https://vatio.ai/docs/sdk/ruby) the page is one
redirect:

```ruby
redirect_to Vatio::Identity.sign_in_redirect_url(
  code: params[:code],
  subject: current_user.id,
  claims: { name: current_user.name, phone_number: current_user.phone }
), allow_other_host: true
```

Without it, sign the JWT as you do for the web and redirect to
`https://vatio.ai/connect/${code}?token=${jwt}`. Always to that host, never to
one taken from the request.

**That redirect has to be a full page load.** When the contact was not signed
in, your login sends them back to this page and this page on to vatio.ai. If
your login form submits with `fetch` (Turbo, the Rails default, does), the
browser cannot follow a redirect to another site from it: the login page just
sits there and the conversation never hears about it. In Rails, answer Turbo's
request with a reload ([vatio-sdk](https://vatio.ai/docs/sdk/ruby#sign-in-from-whatsapp-and-instagram)
shows how); in a single-page app, send the browser to this page with
`window.location.assign`, not a router push.

The token stays with the contact, so later conversations start signed in until
its `exp`, and then the button goes out again. Give it a longer `exp` than a web
token (the gem uses a week): the web gets a fresh one on every page load,
WhatsApp only when the contact signs in.

**Confirming the number.** A link can be forwarded: someone could ask for a
button on their own WhatsApp and send it to one of your users, whose account
would then answer on the wrong phone. So Vatio shows what is being connected
(`•••• 5678` on WhatsApp, `@handle` on Instagram) and asks the person to
confirm. On WhatsApp, put a `phone_number` claim in the token and, when it
matches the WhatsApp number, that step is skipped. Instagram has no number to
match, so there it is always asked.

Your API sees nothing new: a private tool sends the same `$auth.token`, and you
verify it the same way.

### Private tools

`https://vatio.ai/docs/authentication/tools`

#### What a tool sees

| Placeholder | Value |
|---|---|
| `$auth.subject` | The token's `sub` |
| `$auth.token` | The raw JWT, to forward to your API |
| `$auth.claims.<name>` | Any custom claim |

```yaml
### tools/my_bookings.yml
description: The visitor's upcoming bookings.
when_to_use: When they ask about their own bookings.
access: private
request:
  method: GET
  base_url: $env.API_URL
  path: /api/bookings
  headers:
    Authorization: Bearer $auth.token
respond:
  data:
    bookings: bookings
```

Your endpoint verifies the same JWT with the same public key, and scopes the
query to its `sub`.

#### Verifying on your endpoint

Vatio verifies the token before it unlocks a private tool, but that does not
protect your API: the endpoint is on the internet, and anyone can call it with
a made-up header. Verify the forwarded token on every request and check:

- **Signature**, with the public half of the key you sign with, and the
  algorithm pinned (`ES256` or `RS256`), never read from the token's header.
- **`aud`** is your workspace slug, so a token signed for another workspace is
  refused.
- **`exp`** has not passed.

Then take the user from `sub`, and from nothing the request body says. A token
that fails any check is a `401`.

In Rails, [`vatio-sdk`](https://vatio.ai/docs/sdk/ruby) does all of it:

```ruby
class Api::BookingsController < ApplicationController
  include Vatio::Identity::Authentication
  before_action :authenticate_vatio!   # 401 unless the token verifies

  def index
    render json: Booking.where(user_id: vatio_subject)
  end
end
```

If your app serves several workspaces, each with its own key, build a
`Vatio::Identity::Config` per workspace and return it from
`vatio_identity_config` in the controller; see
[Several workspaces](https://vatio.ai/docs/sdk/ruby#several-workspaces).

Anywhere else, any JWT library will do. In Node, with
[`jose`](https://github.com/panva/jose):

```js
import { importSPKI, jwtVerify } from "jose";

const key = await importSPKI(process.env.VATIO_IDENTITY_PUBLIC_KEY, "ES256");

app.get("/api/bookings", async (req, res) => {
  const token = req.headers.authorization?.replace(/^Bearer /i, "");
  try {
    const { payload } = await jwtVerify(token, key, {
      audience: "acme",          // your workspace slug
      algorithms: ["ES256"]
    });
    res.json(await bookingsFor(payload.sub));
  } catch {
    res.status(401).json({ error: "unauthorized" });
  }
});
```

Nothing to verify when no token is sent: public tools carry no `$auth`. On
WhatsApp and Instagram a private tool sends the token the contact signed in
with ([On WhatsApp and Instagram](https://vatio.ai/docs/authentication/sessions#on-whatsapp)).

#### Rules

**Never take the user's id as a parameter.** The model fills parameters, and it
can be talked into filling that one with someone else's id. A private tool
declaring `user_id`, `customer_id`, `account_id`, `member_id`, `patient_id` or
`subject` fails the deploy:

```text
tool my_bookings: a private tool cannot take "user_id" as a parameter — the model
fills parameters and can be talked into filling that one with someone else's id.
Use $auth.subject, which comes from the verified token
```

Derive the user from the token instead. An endpoint that accepts an id has to
remember to scope it every time, forever; one that reads the token cannot forget.

Other errors you may hit:

| Error | Fix |
|---|---|
| `access: private needs an auth: block` | Add `auth:` with `public_key:` |
| `auth.public_key ... is a PRIVATE key` | Point at `identity.pub`, not `identity.pem` |
| `auth.algorithm must be one of RS256...` | Sign with RS256/ES256, not HS256 — a shared secret cannot live in a manifest |
| `access must be "public" or "private"` | Scheme names are gone; use `private` |
| `auth/ holds JavaScript auth providers, which are gone` | Delete `auth/`, sign a JWT instead |
| `JavaScript tools are gone` | Delete `tools/*.js` and `lib/*.js`; describe the call in `tools/<key>.yml` |

A rejected token — wrong key, expired, wrong `aud` — leaves the visitor
anonymous rather than erroring, and the agent falls back to its public tools.
`vatio chat` shows the reason.

### Channels

`https://vatio.ai/docs/channels/`

The same agent serves web chat, WhatsApp, Instagram, and CLI conversations.
Use the actual channel when testing channel identity or message delivery.

- [Web widget](https://vatio.ai/docs/channels/widget) — One script tag on your own site.
- [WhatsApp](https://vatio.ai/docs/channels/whatsapp) — A shared test number, then your own business number for live traffic.
- [Instagram](https://vatio.ai/docs/channels/instagram) — DMs on a professional account, with the same agent behind them.
- [Inbox](https://vatio.ai/docs/channels/inbox) — Where your team watches conversations and takes one over.

#### Channel behavior

WhatsApp and Instagram continue the latest conversation until the last visitor
message is more than eight hours old. The next message then starts a new chat.
On the shared preview number or account, `/new` starts one right away.

Replies are text-only. Supported inbound images are passed to the model, a
voice note reaches it as its transcript, and a PDF is read once when it
arrives, so the agent answers from what it says; supported files must fit the
8 MB per-file limit. A video (MP4, MOV, WebM or MPEG, up to 14 MB) is watched
once when it arrives, and the agent reads what is said in it and what it
shows. Unsupported or oversized
attachments remain available for supervisors, and the agent receives a notice
that it cannot read them.

Web chat receives one complete reply, which the widget renders progressively.
WhatsApp and Instagram receive paced messages with typing indicators. CLI
replies are immediate when ready. These are delivery styles, not separate agents.

### Web widget

`https://vatio.ai/docs/channels/widget`

Try the agent on the inbox's **Test** page, the link `vatio push` prints.
To put chat in front of customers on your own website, publish the agent and
create a token that allows your site's origin:

```bash
vatio push
vatio publish
vatio tokens create --env live --label website \
  --origin https://acme.com --origin http://localhost:3000
```

Each publishable token has its own allowed origins, and works only from those
plus Vatio's own pages and the app builders. They govern the whole public API — the widget, the
browser SDK and iOS and Android apps alike. Origins are exact `scheme://host[:port]`
values, with no path or wildcard. An iOS app is `ios-app://` plus its bundle
id — see the [iOS SDK](https://vatio.ai/docs/sdk/ios) — and an Android app is `android-app://` plus
its package name — see the [Android SDK](https://vatio.ai/docs/sdk/android).

Every token also accepts any `https` subdomain of the app builders, preview and
published alike, without listing it: Lovable (`lovable.app`,
`lovableproject.com`), Replit (`replit.dev`, `replit.app`), Bolt (`bolt.host`,
`webcontainer-api.io`), v0 (`vusercontent.net`) and Base44 (`base44.app`). A
token with no origins works only there. Once the app moves to a domain of its
own, list that one.

A page on an origin the token does not accept gets `origin_not_allowed`, and the
browser console names that origin, ready to paste. Change a token's list with `vatio tokens origins PREFIX URL...` or on
the tokens section of **Channels → Web widget** in the inbox, which also lists
the token in the install snippet that page shows.

Copy the returned `vatpub_` token into your page:

```html
<script async src="https://cdn.vatio.ai/v1/widget.js"
        data-workspace="acme"
        data-token="vatpub_REPLACE_ME"></script>
```

Publishable tokens are intended for page source and select one workspace and
environment. They do not authorize deployment or access to the workspace inbox.
Never use a `vat_` developer token in a browser.

#### From your app's code

With a bundler, install the widget from npm and mount it yourself. It is the
same widget, with the same options as the `data-*` attributes, in camelCase:

```sh
npm install @vatio-ai/widget
```

```js
import { mountVatioWidget } from "@vatio-ai/widget";

const widget = mountVatioWidget({ workspace: "acme", token: "vatpub_REPLACE_ME" });

widget.identify(jwt); // who is signed in now, or null to sign out
widget.unmount();     // closes the connection and removes the widget
```

In React, mount it in an effect and return `widget.unmount` as its cleanup.
There is one widget per page: a second `mountVatioWidget` does nothing until
the first is unmounted. The widget is open source (MIT), at
[github.com/vatio-ai/widget](https://github.com/vatio-ai/widget).

`vatio env` prints an environment's install snippet with its token; the
**Channels → Web widget** in the inbox edits the widget's look and its tokens'
origins.
`vatio tokens list` lists tokens; `vatio tokens revoke PREFIX` revokes one.
Revocation prevents new chats and conversation lists with that token; existing
chat credentials remain valid until expiry.

#### Widget configuration

Visual values resolve in this order: page override, workspace setting, platform
default. Set the shared defaults in **Channels → Web widget** in the inbox, and
use `data-*` attributes for a specific page.
These settings belong
to the workspace, not to a deployment: they apply to every environment as soon
as you save. `widget:` and `allowed_origins:` in an older `vatio.yml` are
ignored, with a warning on push.

The logo, the brand color and `about` belong to the business: they are set on
the [Business](https://vatio.ai/docs/channels/inbox#business) page of the inbox, or with
[`vatio business set`](https://vatio.ai/docs/cli/business), and marked † below. Everything else is
in Channels → Web widget.

| Field | Page attribute | Value / default |
|---|---|---|
| `accent_color` † | `data-accent` | `#RRGGBB` brand color |
| `accent_ink` | `data-accent-ink` | `#RRGGBB`; otherwise automatic contrast |
| `surface`, `ink`, `muted`, `line` | `data-surface`, `data-ink`, `data-muted`, `data-line` | `#RRGGBB` theme colors |
| `scheme` | `data-scheme` | `auto`, `light`, or `dark`; default `auto` |
| `position` | `data-position` | `right` or `left`; default `right` |
| `reply_style` | `data-reply-style` | `stream`: one answer, written out as it arrives. `paced`: short messages, each after a typing indicator, as on WhatsApp and Instagram. Default `stream` |
| `font` | `data-font` | CSS font stack; `inherit` uses the host's font |
| `radius` | `data-radius` | CSS length such as `16px`; default `16px` |
| `title` | `data-title` | Up to 200 characters; defaults to the agent's name |
| `greeting` | `data-greeting` | Up to 200 characters; defaults to localized copy |
| `suggestions` | `data-suggestions` | Up to four prompts, 200 characters each; attribute uses pipe-separated text |
| `about` † | `data-about` | Up to 2,000 characters of visitor-facing copy |
| `locale` | `data-locale` | `en`, `es`, or `pt`; default `en` |
| Logo † | — | PNG, JPEG, WebP, or GIF upload, up to 2 MB |

`about` is displayed to visitors; the business description the agent works
with is set on the [Business](https://vatio.ai/docs/channels/inbox#business) page of the inbox. `locale` sets interface labels; the agent's language
follows the conversation.

For example, `data-suggestions="Products|Track an order"` sets two prompts.

The widget normally waits for page load and idle time; `data-eager="true"`
starts it immediately.

To tell the agent who is signed in, render a visitor token onto the same tag —
see [Sessions and channels](https://vatio.ai/docs/authentication/sessions). To build your own UI
instead of embedding this one, use the [browser SDK](https://vatio.ai/docs/sdk/).

#### Visitor feedback

The widget asks how the conversation went when Jev detects a resolved request, visitor confirmation of closure, and no pending question or action. Evaluation runs in the background a few seconds after the agent's last message, using the latest 12 user and assistant messages (up to 4000 characters each), rather than message counts or inactivity; a visitor who writes again in those seconds is not asked. The question is written for that conversation in the agent's voice, and falls back to “How did [agent name] help you?” when it cannot be.

Feedback is offered once per conversation: Good, So-so, or Bad, with an optional comment of up to 2000 characters for So-so and Bad. Visitors can skip it or continue chatting. Ratings appear in the inbox separately from supervisor ratings, and in `visitor_feedback` on the developer chat API.

### WhatsApp

`https://vatio.ai/docs/channels/whatsapp`

Before you publish, test from the **WhatsApp** tab of the inbox's **Test**
page. Your own number is connected in **Channels → WhatsApp**, once you
publish (`vatio whatsapp` prints the link). What that page opens depends on the
environment the inbox shows, picked at the top of its rail: on **preview**, the
shared preview number and your test phones; on **live**, your own number.
Before you publish, the link `vatio whatsapp` prints opens preview.

#### Test with the shared preview

You do not need your own Meta account.

1. Open **Test → WhatsApp**. A phone you already verified on another
   workspace you own is listed under **You already verified these numbers**:
   choose **Use on preview** and it is connected, with no code.
2. Otherwise, under **Connect your phone**, scan the QR code with your phone.
   WhatsApp opens on the shared preview number with a message already written
   that carries a 6-character code of letters and digits; send it. With no
   camera at hand, send that code to the preview number yourself: the page
   shows both. That message is what proves the phone is yours. Vatio confirms
   it on WhatsApp right away, and the page updates by itself.
3. Write to the agent from that phone, and read the conversation in the
   inbox's **Conversations**, on preview. Under each of the agent's replies is
   what it did: the tools it called and what they returned, and the knowledge
   it read.

To start over, send `/new`. The next message begins a new
conversation, signed out, instead of continuing the current one until it has
been quiet for eight hours. It works only on the shared preview number.

To test from more phones, choose **Add another number** under the list; it
shows a new QR code.

A test phone reaches `preview`, never live: that is your own number.

You can register up to five test phones per workspace. A code lasts 15 minutes
and works once; the QR code on the page renews itself. A verified phone can
route to only one workspace at a time: sending another workspace's code from
it, or choosing **Use on …** there, moves it to that workspace.

#### Connect your business number

For live traffic, open **Channels → WhatsApp** on live and choose
**Connect WhatsApp**. Complete Meta's signup in the window that opens.
**Publish to live** on the **Test** page publishes through the same checks
as [`vatio publish`](https://vatio.ai/docs/deployments#publishing); after the first publish,
**Published. Next: put it in your app** starts the same signup.

A newly connected number is paused: it keeps working exactly as before until
you switch **Live** on, which is what starts replies from your live
deployment — publish your agent first. The same page says whether Meta is
delivering the number's messages; **Verify** runs that check again and
resubscribes the number. Switching **Live** off pauses replies; deleting the
integration removes the connection. **Renew access token** reconnects the same
number. Delete the integration first to change numbers.

##### Who the agent answers

By default the agent answers everyone who writes to your number. To have it
answer only some numbers, while you answer the rest yourself, list them under
**The agent answers** on the same page, one per line with the country code, or
run `vatio whatsapp only +56912345678`. Everyone else's messages still arrive
in [the inbox](https://vatio.ai/docs/channels/inbox); the agent just does not answer them. The
numbers of your [Claude Code routines](https://vatio.ai/docs/integrations/claude-routines) always
count. Empty the list, or run `vatio whatsapp everyone`, to answer everyone
again.

Set the list before you switch **Live** on, and the agent never answers
anyone else.

##### When your client has the number

The number is often your client's, under a Facebook account you do not have.
Under **Does your client have the number?** on the same page, enter their
email and choose **Invite to connect**. They join the workspace as a
supervisor and get an email linking to a page where they complete Meta's
signup from their own account; the same page renews the access token later.
Below the form, the page shows how far each person you invited has got:
invite sent, email link opened, connect page reached (which means signed in),
in Meta's signup, left it without finishing (and at which step), or
connected, with the full history a click away. You get an email once
the number is connected. It still arrives paused, and
switching **Live** on, **Verify** and deleting the integration stay in
**Channels → WhatsApp**. The link under the form opens that page for anyone already on your
team, and for nobody else.

The connection flow supports keeping your WhatsApp Business phone app.
A reply sent from that app starts human takeover on the conversation; release
the chat in [the inbox](https://vatio.ai/docs/channels/inbox) when the agent should resume.

An edit, by the contact or from that app, within WhatsApp's 15 minutes,
rewrites the message in place: the inbox marks it **Edited** and the agent reads
the new text from then on. It is not a new message, so the agent does not answer
it and it starts no takeover. A message deleted for everyone, by the contact
within WhatsApp's two days or from that app, is deleted from Vatio too: it
leaves the inbox and the agent's history.

A forwarded message keeps WhatsApp's label: the inbox marks it **Forwarded**, or
**Forwarded many times** past five forwards. The agent knows the words came
from another chat, so it takes them as something the contact is showing it
rather than their own request, and does not vouch for what a chain message
claims.

A private tool needs the contact signed in. With `auth.sign_in_url` set, Vatio
sends them a **Sign in** button that opens your site, and the conversation
carries on once they are back. See [On WhatsApp and Instagram](https://vatio.ai/docs/authentication/sessions#on-whatsapp).

When a conversation wraps up, Vatio asks the contact how it went: a few
seconds after the agent's last message, a question in the agent's own words
with three buttons, **Good**, **So-so** and **Bad**. It asks once per
conversation, and not if the contact writes again first. The answer lands in
[the inbox](https://vatio.ai/docs/channels/inbox), next to the
[widget's](https://vatio.ai/docs/channels/widget#visitor-feedback), and never reaches the agent.

#### Send from your backend

Your backend can also write first: it calls
[`POST /api/server/v1/:slug/messages`](https://vatio.ai/docs/api/messages) with a number and a
brief, such as "order #1042 shipped today", or with your own `text`. If the
person wrote in the last 24 hours, your text goes out as is, or the agent
writes the message from the brief. Otherwise WhatsApp only delivers an approved
template, so send one along and Vatio uses it; create it with
[`vatio templates create`](https://vatio.ai/docs/cli/webhooks#templates). When the person answers, the
agent has the brief in front of it, and your backend hears about it through a
webhook.

### Instagram

`https://vatio.ai/docs/channels/instagram`

Before you publish, test from the **Instagram** tab of the inbox's **Test**
page. Your own account is connected in **Channels → Instagram**, once you
publish (`vatio instagram` prints the link). What that page opens depends on
the environment the inbox shows, picked at the top of its rail: on
**preview**, the shared preview account and your test accounts; on **live**,
your own account. Before you publish, the link `vatio instagram` prints opens
preview.

#### Test with the shared preview

1. Open **Test → Instagram**. An account you already verified on another
   workspace you own is listed under **You already verified these accounts**:
   choose **Use on preview** and it is connected, with no code.
2. Otherwise, under **Connect your account**, scan the QR code with your
   phone: it opens the DM with the shared preview account. **From the account
   you will test with**, send it the code the page shows. On a computer, where
   ig.me links don't open, DM the account the page names and send the code.
   That DM is what proves the account is yours: Vatio confirms it right there,
   and the page updates by itself.
3. Keep talking to the agent in that DM, and read the conversation in the
   inbox's **Conversations**, on preview.

To start over, send `/new`. The next message begins a new
conversation, signed out, instead of continuing the current one until it has
been quiet for eight hours. It works only on the shared preview account.

Instagram does not let Vatio write first, so your account sends the code
instead of receiving one. A code lasts 15 minutes and works once; the one on
the page renews itself. An account routes to one workspace at a time: sending
another workspace's code from it, or choosing **Use on …** there, moves it to
that workspace.

To test from more accounts, choose **Add another account** under the list; it
shows a new code.

A verified test account reaches `preview`, never live. Up to five are allowed
per workspace.

#### Connect your professional account

For live DMs, open **Channels → Instagram** on live and choose
**Connect Instagram**. Complete Meta's consent in the window that opens; the
same page then says whether the account is receiving DMs, and **Verify** runs
that check again. **Publish to live** on the **Test** page publishes through
the same checks as [`vatio publish`](https://vatio.ai/docs/deployments#publishing); after the first
publish, **Published. Next: put it in your app** starts the same consent.

A workspace connects one account; an account connects to one workspace.
Vatio refreshes its access token automatically. If it expires, **Reconnect**
the same account. Delete the integration before switching accounts.

When a contact edits a DM, the inbox marks it **Edited** and the agent reads the
new text from then on; an unsent DM is deleted from Vatio too, leaving the inbox
and the agent's history. Neither is a new message, so the agent does not answer
it. An account connected before edits were supported starts getting them within
a day, or right away with **Verify**.

A private tool needs the contact signed in. With `auth.sign_in_url` set, Vatio
sends them a **Sign in** button that opens your site, and the conversation
carries on once they are back. See [On WhatsApp and Instagram](https://vatio.ai/docs/authentication/sessions#on-whatsapp).

### Integrations

`https://vatio.ai/docs/integrations/`

Vatio connects to the services your agent and your team already use. Each one
has its own page with everything it does, how to connect it, and what it can
and cannot see.

- [Supabase](https://vatio.ai/docs/integrations/supabase) — Your users sign in with Supabase Auth: the widget, WhatsApp and Instagram know who they are, with no code.
- [Claude Code](https://vatio.ai/docs/integrations/claude-code) — Skills that build your agent and fix what supervisors flag.
- [WhatsApp](https://vatio.ai/docs/channels/whatsapp) — Your business number, through Meta. A channel, so it lives under Channels.
- [Instagram](https://vatio.ai/docs/channels/instagram) — DMs on a professional account, through Meta. Also under Channels.

#### At a glance

| Integration | What it gives your agent | Where you connect it | What Vatio can reach |
|---|---|---|---|
| [Supabase](https://vatio.ai/docs/integrations/supabase) | Signed-in visitors on every channel, and tools that read their rows under your Row Level Security | `vatio auth --supabase`, or **Channels → Supabase** in the inbox | The project's public keys; with the inbox connection, read-only access to the organization's projects, public API keys and auth settings |
| [Claude Code](https://vatio.ai/docs/integrations/claude-code) | A coding agent that builds and improves your Vatio agent | `/plugin install` in Claude Code, or `npx @vatio-ai/skills` for any coding agent | Nothing: the skills run on your machine, through the CLI |
| [Claude Code routines](https://vatio.ai/docs/integrations/claude-routines) | A tool that hands a WhatsApp conversation with your numbers to a routine on claude.ai, which reads it and can answer | `vatio routines create`, then `routine:` in a tool file | Nothing: the routine reads the conversation with a server key you give it |
| [WhatsApp](https://vatio.ai/docs/channels/whatsapp) | Conversations on your business number | **Channels → WhatsApp** in the inbox | The number you connect |
| [Instagram](https://vatio.ai/docs/channels/instagram) | Conversations in your DMs | **Channels → Instagram** in the inbox | The account you connect |

#### Your own backend

Your API is not an integration to install: the agent calls it through
[tools](https://vatio.ai/docs/tools), each one an HTTP request described in YAML, and it tells your
backend what happened through [webhooks](https://vatio.ai/docs/cli/webhooks). If your users sign in
to that backend themselves, sign a JWT for them as
[Authentication](https://vatio.ai/docs/authentication/) describes.

### Supabase

`https://vatio.ai/docs/integrations/supabase`

**If your users already sign in with Supabase Auth, there is nothing to sign
and no code to write.** Name the project and Vatio verifies the Supabase
session itself, on the web, on WhatsApp and on Instagram.

```bash
vatio auth --supabase
```

That finds the project URL where your app already keeps it (a `.env`, or the
`src/integrations/supabase/client.ts` Lovable generates), checks that it signs
with keys Vatio can verify, and writes:

```yaml
### vatio.yml
auth:
  supabase: https://abcdefghijklmnopqrst.supabase.co
```

It also stores the project URL and its anon key as the secrets
`SUPABASE_URL` and `SUPABASE_ANON_KEY`, for [your tools](#tools). Pass the URL
yourself if it cannot find it: `vatio auth --supabase https://<ref>.supabase.co`.

##### Or connect it from the inbox

Under **Channels → Supabase** in the inbox, **Connect Supabase** signs in to Supabase
and asks you to pick the project (none to pick if the organization has just
one). Vatio sets `SUPABASE_URL` and `SUPABASE_ANON_KEY` from it, and shows the
`auth:` block to add to `vatio.yml`. From then on `vatio auth --supabase` uses
that project instead of looking in your `.env`.

The connection reads the organization's projects, public API keys and auth
settings, never your data, and never stores a secret or service-role key.
**Which project verifies visitors is still the one `vatio.yml` names**, so
preview can point at a staging project and a rollback brings back the project
it deployed with. `vatio diff` warns when the two disagree. Disconnecting revokes
Vatio's access and leaves the secrets and `vatio.yml` as they are.

Vatio still only verifies: it reads the project's **public** keys from
`/auth/v1/.well-known/jwks.json`, which can check a token but never create
one. It never sees a refresh token.

#### Before you start: signing keys

Vatio needs your project to sign with an asymmetric key (ES256 or RS256). A
project still on the legacy JWT secret (HS256) publishes no public keys, and
the push is refused with this fix:

1. In the Supabase dashboard, open **Project Settings → JWT Keys**.
2. **Migrate** the JWT secret, then **rotate** to the ES256 key it creates.

Your app keeps working through the switch; supabase-js does not change.
Custom domains are not supported yet: use the `https://<ref>.supabase.co` URL.

#### On the web

Put the widget on your site as usual. **That is all.** The widget reads the
Supabase session your page already has, where supabase-js keeps it
(`localStorage`, or the cookies `@supabase/ssr` writes):

- **Signed in:** the visitor is known from their first message.
- **Signs in mid-conversation:** the same conversation carries on, now with a
  name on it ([Signing in mid-conversation](https://vatio.ai/docs/authentication/sessions#signing-in-mid-conversation)).
- **Signs out, or someone else signs in:** a new conversation. One person never
  sees another's.
- **Token refreshed:** supabase-js replaces the access token every hour and the
  widget picks up each new one, so a long conversation keeps working.

To decide yourself, pass the token and the widget stops reading the session:

```js
window.VatioWidget.identify(session.access_token); // null to sign out
```

That is also how the mobile SDKs and `@vatio-ai/sdk` do it: pass
`session.access_token` as the visitor token.

#### On WhatsApp and Instagram

Add your login page as `sign_in_url`, and keep the widget script on it:

```bash
vatio auth --supabase --sign-in-url https://your-app.com/login
```

When a private tool needs a signed-in contact, Vatio sends a **Sign in**
button. It opens your login page with `?vatio_sign_in=<code>`, and the widget
takes it from there:

1. Already signed in on that phone? The widget hands the session to Vatio at
   once. Nothing to type.
2. Not signed in? They sign in the way your site already works (password, magic
   link, Google). Your redirects can go anywhere on your site with the widget
   on it; the code is kept for the tab for 10 minutes.
3. Vatio verifies the token. On WhatsApp, when the account's `phone` matches the
   WhatsApp number, that is it; otherwise the person confirms the number
   (`•••• 5678`) or the Instagram handle, so a forwarded link cannot connect
   someone else's account.
4. The conversation picks up where it left off, and the agent runs the tool it
   was waiting on.

A Supabase access token lasts an hour by default, so the contact is asked to
sign in again in a later conversation. On a phone that is already signed in it
is one tap. To make it rarer, raise **Access token expiry** under
**Project Settings → JWT Keys**.

#### Tools

Mark a tool `access: private` and forward the token. Supabase's REST API
answers as the visitor, so **your Row Level Security decides what they see**,
exactly as in your app:

```yaml
### tools/my_children.yml
description: The visitor's children and their next appointments.
when_to_use: They ask about their own children or bookings.
access: private
request:
  method: GET
  base_url: $env.SUPABASE_URL
  path: /rest/v1/children
  query:
    select: "name,next_appointment"
  headers:
    apikey: $env.SUPABASE_ANON_KEY
    Authorization: Bearer $auth.token
respond:
  data:
    children: "$"
  message:
    when_empty: They have no children registered.
    default: "Found {{count}} children."
```

A database function works the same way, as `POST /rest/v1/rpc/<name>`, and so
does an Edge Function, as `POST /functions/v1/<name>`.

Never give a tool the service-role key: it reads past every policy. With the
visitor's own token, the model cannot ask for anyone else's rows, because the
database will not return them.

#### What a tool sees

| Placeholder | Value |
|---|---|
| `$auth.subject` | The Supabase user id (`auth.uid()`) |
| `$auth.token` | The access token, to forward |
| `$auth.claims.email`, `$auth.claims.phone` | From the token |
| `$auth.claims.user_metadata`, `$auth.claims.app_metadata` | From the token |

The contact in the inbox gets the email, the phone, and the name from
`user_metadata.full_name` (or `name`). To show a profile link and badges in the
inbox, add a `metadata` claim with a
[Custom Access Token Hook](https://supabase.com/docs/guides/auth/auth-hooks/custom-access-token-hook);
see [Enrich the inbox](https://vatio.ai/docs/authentication/#enrich-the-inbox).

### Claude Code routines

`https://vatio.ai/docs/integrations/claude-routines`

**Ask your agent on WhatsApp, and Claude gets to work.** A
[Claude Code routine](https://code.claude.com/docs/en/routines) is a saved
prompt, with its repositories and connectors, that runs in the cloud. Connect
one to Vatio, give it to your agent as a tool, and when one of the numbers you
trust asks for something Claude should do, the agent hands the conversation
over. What Claude does next is up to the routine's prompt: review something,
change code and open a pull request, answer a client.

Setting it up for the first time? Follow
[Connect Vatio to Claude Code](https://vatio.ai/docs/connect-claude-code), step by step. This page
is the reference.

```sh
vatio keys create --env live --label encargos
vatio routines create encargos \
  --url https://api.anthropic.com/v1/claude_code/routines/trig_…/fire \
  --phone +56912345678
```

```yaml
### tools/encargos.yml
description: Pass a request to Claude, which reviews or changes the code of our apps in a separate session.
when_to_use: Someone asks to review or change something in our apps and you know what it is. If what to change or where is missing, ask first.
routine: encargos
```

```yaml
### vatio.yml
agent:
  tools: [encargos]
```

#### What a routine is

**Your agent serves your users. A routine is for what needs Claude**: changing
your app, fixing something, looking into something. The agent decides when,
Claude works in your code, and the agent tells the person.

It is a tool, with two differences from the ones that call your API:

| | Who can use it | When it ends | What comes back to the conversation |
|---|---|---|---|
| An [HTTP tool](https://vatio.ai/docs/tools#declarative-http-tools) | Anyone the agent talks to (or signed-in users, with `access: private`) | In seconds, before the agent answers | A result the agent uses in its answer |
| A routine tool | Only conversations with the routine's numbers, on WhatsApp | Minutes or hours later, outside the chat | That Claude has it, with the link to its session; later, what Claude tells the agent to pass on |
| A [handoff](https://vatio.ai/docs/handoff) | Anyone, by your rules or the agent's | When a person takes the conversation | The conversation, to a person |

The agent chooses the moment, so it can ask what is missing first: "implement
the requirement" gets a question back, not a session that has to guess.
Vatio decides who the tool exists for, in code: anyone else's conversation
never has it, so nobody can talk the agent into it.

#### How it works

1. One of the routine's numbers writes on WhatsApp, and the agent answers as
   always. In that conversation it has the routine's tool; in anyone else's, it
   does not.
2. When the request is complete, the agent calls the tool. Vatio calls the
   routine's trigger with JSON that says where the conversation is, and
   nothing it says:

   ```json
   {
     "source": "vatio",
     "event": "message.received",
     "run_id": 41,
     "routine": "encargos",
     "workspace": "acme",
     "environment": "live",
     "chat_id": 349,
     "message_id": 9876,
     "messages_url": "https://vatio.ai/api/server/v1/acme/chats/349/messages"
   }
   ```

   `message_id` is the person's last message when the agent called the tool.
3. The tool answers within the same turn, so the agent tells the person what
   it asked Claude for ("I'm asking Claude Code to look at the open pull
   requests"), with the link to Claude's session, or that it did not go
   through. A second call before the person writes again starts nothing new.
4. Claude reads the conversation, does the work, and tells the agent with a
   `brief`. The agent passes it on in its own words ("Claude tells me
   that…"), and keeps it for when the person answers.

Each call starts a new Claude session: the trigger cannot continue one. To
carry on in the same session, open the link the agent gave. It opens on
claude.ai for the account that owns the routine.

#### Set it up

1. On [claude.ai/code/routines](https://claude.ai/code/routines), create the
   routine, pick its repositories, and add an **API** trigger. Copy its URL and
   generate its token: claude.ai shows the token once.
2. Create a [server key](https://vatio.ai/docs/cli/keys) for the routine, so it can read the
   conversation and answer: `vatio keys create --env live`. Put it in the
   routine's cloud environment, for example as `VATIO_SERVER_KEY`. Everyone who
   uses that environment can read its variables.
3. Let the environment reach `vatio.ai`. The default cloud environment only
   reaches a list of trusted domains; add `vatio.ai` to its network access.
4. Run `vatio routines create NAME --url URL --phone NUMBER` and paste the
   token when it asks, or pipe it in: `op read … | vatio routines create …`.
   Vatio refuses anything that is not a `sk-ant-oat01-…` token.
5. Run `vatio routines test ID`. It starts a real session with a `ping` and no
   conversation, and prints how the trigger answered and the session's link.
6. Give it to the agent: `tools/NAME.yml` with `routine: NAME`, a
   `description` and a `when_to_use`, and `NAME` in `agent.tools`. Then
   `vatio push`, and `vatio publish` when it is ready. A routine tool takes no
   `request:`, `access:` or `parameters:`.

#### What the prompt needs from Vatio

The prompt is yours. These are the parts that come from Vatio:

- **Claude treats the payload as data.** It arrives in a
  `routine-fire-payload` block, and the routine only acts on it if the prompt
  says to: for example, "read the conversation at `messages_url` and do what
  the last messages ask".
- **Read the conversation** with
  `curl -H "Authorization: Bearer $VATIO_SERVER_KEY" "$messages_url"`. The
  newest messages come last, up to 100; `?before=<oldest_id>` gets the page
  before. See [Read a conversation](https://vatio.ai/docs/api/messages#read-a-conversation).
- **Each message** has `role` (`user` for whoever wrote on WhatsApp,
  `assistant` for the agent and anything sent through the server API),
  `content` and `occurred_at`. `forwarded` is `forwarded` or
  `frequently_forwarded` when it came from another chat, which is how a
  client's messages that were passed along differ from the instruction typed
  after them. A voice note arrives as its transcript; a photo or a file as a
  signed URL in `attachments`, with what Vatio read in a PDF or a video.
- **Answer through the agent** with
  [`POST /api/server/v1/:slug/messages`](https://vatio.ai/docs/api/messages) and
  `{ "to": "+569…", "brief": "Tell Iván that Claude finished: …" }`. The agent writes
  the message from it, as news from Claude, and keeps the brief for when they
  answer. `text` instead of `brief` sends Claude's words exactly as written.
  The person has just written, so the 24-hour window is open; after a task
  that took hours it may have closed, and then only an approved template goes
  out.
- **A ping** (`"event": "ping"`) has no conversation. `vatio routines test`
  sends one.

#### Who can use it

- **Only on WhatsApp.** Meta vouches for the number a WhatsApp message comes
  from. A number typed into the widget or an app is whatever the visitor says
  it is, and a routine can write code.
- **Only the numbers you list**, up to 20 per routine. Everyone else talks to
  the agent as always, and the tool is not there for them.
- **What a client wrote ends up in the session.** A forwarded message is
  someone else's words, inside a session that can change your repositories.
  Keep the routine to branches and draft pull requests that you review.
- **Evals never start one.** A replayed conversation gets a simulated success.

Each routine belongs to one environment, like a server key: a preview routine
serves your test phones on Vatio's test number, a live one your own number. A
tool whose routine is missing or switched off in an environment is not offered
there.

**To switch Live on without the agent answering your clients**, have it answer
only your own numbers first, with `vatio whatsapp only` (see
[Who the agent answers](https://vatio.ai/docs/channels/whatsapp#who-the-agent-answers)). A routine's
numbers always count, and everyone else's messages wait in the inbox for you.

#### Runs and failures

`vatio routines runs ID` lists the last 50 runs: when, for which chat, how the
trigger answered, and the link to the Claude session it started. The inbox's
**Tools** page shows each call next to your other tools.

The first attempt happens inside the agent's turn. If it surely did not start
anything (a connection that never opened, a 5xx, or a 429), Vatio keeps trying
for a few minutes and the agent says it is on its way; it gives up after 30
minutes, because an instruction that starts an hour late is worse than one that
says it failed. A call that timed out is not retried: the trigger has no
idempotency key, and the session may already be running.

After 10 failed runs in a row, Vatio switches the routine off;
`vatio routines enable ID` turns it back on. A revoked token is the usual
cause: generate a new one on claude.ai and pass it with
`vatio routines update ID --new-token`. claude.ai runs a routine at most 30
times an hour.

### Deployments

`https://vatio.ai/docs/deployments`

#### Preview and live

A deployment is a saved configuration snapshot. An environment is a name
pointing to a deployment, and there are two: **`preview`**, the next version,
which `vatio push` updates; and **`live`**, what visitors talk to.

```bash
vatio push
vatio status
vatio diff --env live
vatio publish
vatio rollback
```

`push` validates and updates preview, and runs no tests; it cannot target
`live`. `publish` promotes preview to live together with the knowledge changes
made in it, once its tests allow it ([Publishing](#publishing)). The
**Publish to live** button on the inbox's **Test** page does the same, through
the same checks; it is not there while preview is already live's version,
when there is nothing to publish.
`rollback` restores the previous live configuration. `diff --env live`
compares your **local directory** with live, so push local edits before
publishing them. `--env` takes `preview` or `live`; `staging` and `sandbox`
are other names for preview, and any other name is an error.

Agents, tools, auth configuration, chats, and contacts belong to one
environment. Preview also keeps the [knowledge](https://vatio.ai/docs/knowledge#preview-and-live)
entries it changed and the [secret](#secrets) values set only for it, and
reads everything else from live. **Channel connections and brand settings are
workspace-wide.** The widget's look is not part of a deployment: it is edited
in **Channels → Web widget** in the inbox. Neither is
the business — its name, description and opening hours are set on the
[Business](https://vatio.ai/docs/channels/inbox#business) page of the inbox. Both apply to both
environments as soon as they are saved.

A deployment that has been live is a snapshot of the whole agent: its
configuration and the version of every knowledge entry it answered from. Live is a pointer to one, and everything that changes what live
answers from leaves a new one behind: a publish, and an edit in the inbox too
(it keeps live's configuration). `vatio rollback`
moves live to the previous snapshot, **configuration and knowledge together**;
run it again to go further back. Rollback does not restore secret values or undo actions a tool already performed.

Preview's chats, contacts and tokens exist to try the agent; visitors are on
live.

#### Draft and published

A workspace is a **draft** until its first `vatio publish`, and **published**
from then on.

- **Draft:** there is no live yet, so preview is the agent. A
  [flag](https://vatio.ai/docs/improve) resolves as soon as `vatio eval` passes it on preview, and
  the first publish has no checks to pass.
- **Published:** visitors talk to live. A flag resolves when live passes it,
  and every publish of a new version goes through the checks below.

#### Publishing

Every reply a supervisor flagged with a note is a test, and `vatio eval`
replays them all against preview, as does **Run tests** on the inbox's
**Test** page. Once the workspace is published, `vatio publish` looks at
preview's current version first:

| | `vatio publish` |
|---|---|
| Already live's version | Nothing moves but preview's knowledge changes, with a notice saying so |
| Not tested | Refused, `evals_missing`: run `vatio eval`, then publish |
| Its run is still going | Refused, `evals_running`: `vatio eval` waits for it |
| A flag that passes on live fails on it | Refused, `evals_regressed`, naming each flag. `--force` publishes anyway |
| Otherwise | Publishes |

What `vatio eval` found on preview goes live with the version: the flags it
passed are resolved with the version's short sha, and live is not replayed
again. Flags that still fail do not block a publish, so a partial fix can go
live. A workspace with no flags publishes without a test. See
[Improve your agent from flags](https://vatio.ai/docs/improve#what-publish-checks).

#### Working with others

There is one preview per workspace. Once the workspace is published, a
`vatio push` from a different git branch than the one preview's unpublished
version came from is refused with `preview_has_unpublished_push`: what is on
preview has not gone live yet, and the push would replace it. Publish it
first, or push with `--force` to replace it. Every version on preview is tried
on the inbox's **Test** page, open to the workspace's owner and developers.

#### Secrets

```bash
vatio secrets set ACME_API https://api.acme.com
vatio secrets set ACME_API_KEY YOUR_BACKEND_KEY
vatio secrets list
vatio secrets rm ACME_API_KEY
```

Keys use uppercase letters, digits, and underscores, starting with a letter;
keys starting with `VATIO_`, and `DISPATCHER`, are reserved for Vatio.
Tools read values with `$env.KEY`. Listing returns names, never
values. Changes take effect on subsequent calls without deploying. Rollback
does not restore old values.

A secret applies to both environments. With `--env preview` it applies to
preview only, replacing the value there — preview's tools can call staging
while live keeps calling production:

```bash
vatio secrets set ACME_API https://staging.acme.com --env preview
```

A **test value** is the one preview uses, and every eval replay, live's
included: one staging URL for everything that is not a visitor on live.
Without one, the usual value is used.

```bash
vatio secrets set ACME_API https://staging.acme.com --tests
```

A tool call from an eval replay also carries an `X-Vatio-Test: 1` header, and a
write (any method but GET) whose URL has no test value is not sent: the agent
gets a success instead. See [Eval runs](https://vatio.ai/docs/improve#eval-runs).

A tool runs on Vatio's servers, so it cannot reach `localhost`; to test against
a backend on your machine, expose it through a tunnel and set that URL.

Keep credentials out of manifests, tool source, knowledge, and tool results.
The workspace upload includes ordinary files, so keep private signing keys
outside that directory.

### Troubleshooting

`https://vatio.ai/docs/troubleshooting`

| Symptom | Check |
|---|---|
| CLI cannot find the workspace | Run from the directory containing `vatio.yml` or below it; inspect with `vatio doctor` |
| CLI authentication fails | Run `vatio login`; verify the host with `vatio doctor` |
| Manifest or tool validation fails | Run `vatio diff` and fix the reported file; unknown root keys and missing instructions are errors |
| A chat was answered by an older version | Run `vatio chat show CHAT_ID`: its header names the environment and deployment that answered. Compare with `vatio status`, then `vatio publish` |
| `vatio publish` is refused with `evals_missing`, `evals_running` or `evals_regressed` | Preview's version is untested, still testing, or breaks a flag live passes: run `vatio eval` and fix what it lists. See [Publishing](https://vatio.ai/docs/deployments#publishing) |
| `vatio push` is refused with `preview_has_unpublished_push` | Preview holds a version from another branch that is not live yet: publish it first, or push with `--force` to replace it. See [Working with others](https://vatio.ai/docs/deployments#working-with-others) |
| Agent never calls a tool | Add its key to `agent.tools`, describe when to use it, push, and start a fresh test chat |
| Knowledge answers are missing | Check `vatio kb show BASE`, `knowledge` references, and the `knowledge_lookup` tool assignment |
| Agent cannot share a URL | Check it is written verbatim in `links`, in `instructions`, or in a tool result — an assembled or edited URL is stripped |
| Widget does not appear | Check `vatio env` (its snippet and token), the token's environment, a deployed agent, and that the host's exact origin is in that token's allowed origins (`vatio tokens list`, or the tokens section of **Channels → Web widget** in the inbox) |
| Signed-in visitor appears anonymous | Check `auth.public_key` matches the key you sign with, and the JWT's signature, `exp` and `aud` (the workspace slug) |
| Signed-in visitor appears anonymous, with `auth: supabase:` | The project still signs with the legacy JWT secret (Project Settings → JWT Keys → migrate and rotate), or the app passes a custom `storageKey` to supabase-js — then call `VatioWidget.identify(session.access_token)` yourself. See [Supabase](https://vatio.ai/docs/integrations/supabase) |
| WhatsApp or Instagram is connected but silent | Run the channel's `check`; confirm webhook reception and a live agent; WhatsApp must also be activated |
| Agent stopped replying to one chat | Check for human takeover in the inbox and release it when ready |

#### Issues

Report bugs and feature requests on the public repository for the piece that
misbehaved:

- CLI and platform: [github.com/vatio-ai/cli/issues](https://github.com/vatio-ai/cli/issues)
- Browser SDK: [github.com/vatio-ai/sdk/issues](https://github.com/vatio-ai/sdk/issues)
- iOS SDK: [github.com/vatio-ai/ios/issues](https://github.com/vatio-ai/ios/issues)
- Android SDK: [github.com/vatio-ai/android/issues](https://github.com/vatio-ai/android/issues)
- Ruby gem: [github.com/vatio-ai/ruby/issues](https://github.com/vatio-ai/ruby/issues)

Include the failing command, the observed error, a `request_id` when one is
printed, and the output of `vatio doctor`. Issues are public: remove
credentials and customer data before you post.

## CLI

### CLI

`https://vatio.ai/docs/cli/`

The npm package is `@vatio-ai/cli`; its installed executable is `vatio`. The
rest of this reference uses that shorter form:

```bash
npm install -g @vatio-ai/cli@latest
vatio --help
```

Alternatively, replace `vatio` in any command with `npx @vatio-ai/cli@latest`.
Specifying `@latest` explicitly requests the current release. Update a global
install by running the install command again.

Authorize the machine once, and check what the CLI thinks it is talking to:

```bash
vatio login
vatio doctor
```

Every command below finds the nearest `vatio.yml` at or above the current
directory and reads `workspace` from it. Start with the
[quickstart](https://vatio.ai/docs/quickstart) if you have not deployed an agent yet.

#### Commands

- [Workspace and account](https://vatio.ai/docs/cli/workspace) — init, login, doctor, docs, config.
- [Deployments](https://vatio.ai/docs/cli/deploy) — push, publish, rollback, status, diff.
- [Chats](https://vatio.ai/docs/cli/chat) — chat, and chat show to read any chat back with its tool calls.
- [Flags, evals and tools](https://vatio.ai/docs/cli/improve) — flags, eval and tools: what supervisors reported, how your tools' backend answered, and proving the fix.
- [Knowledge bases](https://vatio.ai/docs/cli/knowledge) — kb: bases and the entries you write into them.
- [Business](https://vatio.ai/docs/cli/business) — business: name, description and opening hours, the same the inbox edits.
- [Secrets](https://vatio.ai/docs/cli/secrets) — secrets: the values tools read as $env.KEY.
- [Publishable tokens](https://vatio.ai/docs/cli/tokens) — tokens and the origins each accepts: what a page carries.
- [Server keys](https://vatio.ai/docs/cli/keys) — keys: what your backend calls the server API with.
- [Templates and webhooks](https://vatio.ai/docs/cli/webhooks) — templates and webhooks: what your backend needs to send WhatsApp messages.
- [Routines](https://vatio.ai/docs/cli/routines) — routines: a Claude Code routine your agent hands work to, as a tool.
- [Channels](https://vatio.ai/docs/cli/channels) — whatsapp and instagram point you at the inbox, where both are set up.
- [Authentication keys](https://vatio.ai/docs/cli/auth) — auth --new-key: the keypair that signs a visitor's JWT.

#### Using it

- [Configuration and uploads](https://vatio.ai/docs/cli/configuration) — Where credentials live, which env vars override them, what a push sends.

### Workspace and account

`https://vatio.ai/docs/cli/workspace`

| Command | Purpose |
|---|---|
| `vatio init [SLUG] [--name NAME]` | Create or use the remote workspace, write a starter manifest in the current directory, and add a Vatio section to its `AGENTS.md` |
| `vatio login [--base-url URL]` | Sign this machine in from the browser — email link, Google or GitHub; creates the account if there is none |
| `vatio logout` | Revoke the saved token on Vatio and remove it from this machine |
| `vatio doctor` | Show Node version, config location, workspace, and token presence |
| `vatio version` | Show CLI and Node versions |
| `vatio docs [--save [PATH]]` | Fetch the docs; print them or save to `vatio-docs.md` / `PATH` |
| `vatio feedback "TEXT"` | Tell the Vatio team what got in the way; also reads `TEXT` from stdin |
| `vatio share-session ["FEEDBACK"] [--yes] [--save FILE] [--list] [--id ID]` | Send this project's Claude Code session to the Vatio team, after showing what goes and asking; feedback is optional |
| `vatio share-session --should-ask` / `--never` | Whether a coding agent should offer to send the session now / stop coding agents from offering |
| `vatio config show` / `get KEY` / `set KEY VALUE` / `unset KEY` | Manage local `base_url` and `token` settings |

`init` writes `vatio.yml` in the **current directory** and asks for no account:
you can write the whole agent without signing up. Identity is what `vatio push`
needs, because it is the first thing that makes something run on a server, and
there the CLI opens the browser on a sign-in page for this terminal and prints
the terminal's code. Sign in with your email (Vatio mails a link that works on
any device) or with Google or GitHub, check the page shows the same code, and
confirm. On a machine with no browser the CLI prints the link instead; a
coding agent running it shows that link to you. If the wait is cut off,
running the same command again picks up the same sign-in page instead of
starting over. Every other command finds the nearest
`vatio.yml` at or above the current directory and reads `workspace` from it —
there is no `--workspace` flag and no `VATIO_WORKSPACE` override.

Reach for `doctor` before anything else when a command cannot find the
workspace or the token: it prints all four things that decide that, and prints
no secrets, so it is the safe one to paste into an issue.

#### Feedback

`vatio feedback` is for coding agents as much as for people: when the docs or
an error left you guessing, say what you were trying to do and what got in the
way, in a sentence or two. It reaches the Vatio team with the commands this
session ran before it, so there is no need to repeat them. It works before
`vatio login`, and with telemetry turned off.

```bash
vatio feedback "push said the tool was invalid but not which field, so I tried three things"
```

#### Share a session

When a sentence is not enough, `vatio share-session` sends the whole Claude
Code session to the Vatio team: what you asked, what your coding agent ran and
what it was shown. It takes the session the coding agent running it is in
(Claude Code tells it which, in `CLAUDE_CODE_SESSION_ID`), or else the most
recent Claude Code session for this directory or one above it.

The session goes on its own. Feedback in your words rides along if you have
some — what got in the way, what you expected — and at a terminal it asks for
it after you say yes (Enter skips it):

```bash
vatio share-session
vatio share-session "I expected push to tell me which tool field was wrong"
```

Before anything leaves your machine it drops images and Claude Code's own
bookkeeping, replaces anything that looks like a key, token, password or
private key with `[REDACTED]`, email addresses and phone numbers written with
a country code (`+56 9 …`) with `[EMAIL]` and `[PHONE]`, and your home
directory with `~`, then shows which session it is, how many messages, from
when to when and how large, and asks. Names, and numbers without a `+`, are
left as they are: `--save` shows what goes. Off a terminal it sends only with
`--yes`: a coding agent passes it after asking the person it works for.

A coding agent's own permission checks can refuse to send it — Claude Code's
auto mode may treat it as sending data out. Run it yourself then, with the
session id it showed (`vatio share-session --id ID --yes`); `--list` marks each
session already sent with its number, so your coding agent can check that it
went.

| Flag | What it does |
|---|---|
| `--save FILE` | Write exactly what would be sent to `FILE`, and send nothing |
| `--list` | List the recent Claude Code sessions for this project, marking the ones already sent `(sent #N, when)` |
| `--id ID` | Send that session instead (a prefix of the id is enough) |
| `--yes` | Send without asking; for a coding agent that already asked you |
| `--should-ask` | Print `yes` or `no` and why: whether a coding agent should offer to send the session now. Sends nothing |
| `--never` | Stop coding agents from offering. Sends nothing |

It needs you signed in, with the same browser sign-in as the first
`vatio push`. Only the Vatio team reads it, to see where developers and their
coding agents get stuck, through an internal tool that logs every read. It is
deleted 30 days after it arrives — the CLI prints the date when it sends —
and what stays is who sent one, when, and your feedback. To have it deleted
sooner, write to dpo@vatio.ai. Claude Code sessions only, for now; from another
coding agent, `vatio feedback` says it in a sentence. In Claude Code the
[`vatio-share-session`](https://vatio.ai/docs/integrations/claude-code) skill runs it for you:
`/vatio-share-session`.

##### When your coding agent offers it

With the Vatio skills, Claude Code offers to send the session when it finishes
building or improving an agent, with three answers: send it, not now, or
don't ask again. Before offering it runs `vatio share-session --should-ask`,
which says `no` when you chose don't ask again (`--never`), when it already
offered in this session or in the last week, outside Claude Code, in CI, and
with telemetry turned off. The answer is kept in `~/.vatio/share-session.json`;
delete it to be offered again. `vatio share-session` still sends a session
whenever you run it.

#### Telemetry

So the Vatio team can see where coding agents get stuck, the CLI reports how
each command ended — its name (`push`, `kb write`), the exit code and, when it
failed, the error it printed — and sends the same name with
each API request. Both carry the CLI version, which coding agent is running it
(from the environment it sets, such as `CLAUDECODE`) and a random session id
that groups the commands run in one directory until 30 minutes pass without
one.

The first command on a machine says so on stderr, once, before anything is
sent. Never sent: arguments, file contents, secret values, or anything a chat said.
Set `VATIO_TELEMETRY=0` (or `DO_NOT_TRACK=1`) to turn it off; requests then
carry only the CLI version. `VATIO_AGENT=NAME` names a coding agent the CLI
does not recognize.

#### Issues

Report problems on the public repositories; see
[Troubleshooting](https://vatio.ai/docs/troubleshooting#issues) for which one and what to include.

### Deployments

`https://vatio.ai/docs/cli/deploy`

| Command | Purpose |
|---|---|
| `vatio push [--force]` | Update `preview`; it runs no tests. The first push signs in like `login` |
| `vatio publish [--force]` | Take `preview` live with its knowledge changes |
| `vatio rollback` | Put live back to the previous snapshot: configuration and knowledge together |
| `vatio status` | Show deployment state |
| `vatio env [preview\|live]` | One environment: its token, widget snippet, test link, knowledge changes and conflicts; default `preview` |
| `vatio env list` | Both environments and what each runs |
| `vatio diff [--env NAME]` | Compare local files with a deployment; default `preview` |
| `vatio diff --stat` / `--name-only` / `--format json` / `--full` | Select diff detail or output format |

There are two environments: `preview`, the next version, which `push`
updates, and `live`, what visitors talk to. `--env` takes one of the two
(`staging` and `sandbox` still mean `preview`); any other name is an error.
`push` cannot target `live`: publishing is a separate, deliberate step. `diff`
compares your **local directory** with a deployment, so push local edits before
publishing them.

#### The publish gate

Before the first publish there is nothing live to protect, so `publish` always
goes through. When preview is already live's version there is nothing to take
live either: `publish` moves only preview's knowledge changes and prints a
notice saying so. Otherwise, `publish` takes preview live only once
[`vatio eval`](https://vatio.ai/docs/cli/improve) has tested preview's current version, and refuses
with one of these `error_key`s otherwise:

| `error_key` | What it means | Next |
|---|---|---|
| `evals_missing` | Preview's version has not been tested | `vatio eval`, then publish |
| `evals_running` | Its tests are still running | `vatio eval` waits for them, then publish |
| `evals_regressed` | A report that passes on live fails on preview; the CLI lists each one by id and title | Fix it, `push` and `eval` again |

Reports that still fail on both do not block: a partial fix can ship. When
`publish` goes through, the reports preview passed are resolved, with the
version that fixed them. `publish --force` publishes over a regression anyway,
and its failing results go live with it; use it only when you know why the
report fails.

#### Pushing over someone else's push

Once the workspace is published, `push` refuses to replace a preview that holds
an unpublished push from another git branch (`preview_has_unpublished_push`):
whoever pushed it has not published it yet. Publish it first, or
`push --force` to replace it. The CLI sends the current git branch with each
push for this.

`publish` is refused when live changed a knowledge entry preview also changed;
see [Conflicts](https://vatio.ai/docs/knowledge#conflicts).

See [Deployments](https://vatio.ai/docs/deployments) for what an environment actually holds, and
what a rollback does and does not restore.

### Chats

`https://vatio.ai/docs/cli/chat`

| Command | Purpose |
|---|---|
| `vatio chat "message" [--env NAME] [--new] [--timeout N]` | Talk as the CLI token holder; default `preview`, timeout 120 seconds. `--new` starts over as a visitor the agent never met |
| `vatio chat show [CHAT_ID] [--env NAME] [--last N] [--json]` | Without an id, the chat you are in; with one, any chat in the workspace — a visitor's on the web, WhatsApp or Instagram too. Messages and tool calls, with the channel, environment and deployment; `--json` for everything |

`show` is the one to reach for when an answer is wrong: it shows which tools
ran, what they returned, and whether the workspace recognized the visitor. A
`[tool limit]` line marks a reply that ran out of tool calls (see
[Tool results](https://vatio.ai/docs/tools#tool-results)): the agent was told to answer with what it
had, and any call after that was refused.

The CLI channel identifies the developer holding the token; it does not
simulate a customer's identity. Test on the real channel when identity or
delivery is what you are checking.

`chat --env live` creates a real live conversation — billed, and visible in the
inbox.

Current chats are remembered per environment in `.vatio-chat.json`, so a second
`vatio chat` continues the first. Add that file to your repository's
`.gitignore`.

### Flags, evals and tools

`https://vatio.ai/docs/cli/improve`

| Command | Purpose |
|---|---|
| `vatio flags` | Every open report as markdown: the conversation, each tool call of that turn with your backend's status, duration and response, and what should have happened |
| `vatio flags --save [PATH]` | The same, written to `vatio-flags.md` or `PATH` to hand to a coding agent |
| `vatio flags --json` | The open reports as JSON rows |
| `vatio eval [--env NAME] [--agent SLUG] [--timeout N]` | Test every report against an environment, `preview` by default, wait for the judge, print the report; exits `1` if a case fails |
| `vatio eval --no-wait` | Start the runs and print their ids |
| `vatio eval --show RUN_ID` | One run's report |
| `vatio tools [--env NAME] [--period 1h\|1d\|7d\|30d]` | The inbox's **Tools** page as markdown: each tool's calls, error rate, timeouts and p50/p95 latency, then the calls that failed with what your backend answered |
| `vatio tools --json` | The same as JSON |

One session of your coding agent takes every open report:

```bash
vatio flags         # every open report, with how to work them at the top
### ... fix vatio.yml, knowledge, tools or the backend they call ...
vatio push          # updates preview; runs no tests
vatio eval          # tests every report on preview; exits 1 if one fails
vatio publish       # takes preview live, once eval has tested it
```

Every report with a note is a test, wherever it was made. `vatio eval` runs
them on Vatio's servers, five at a time, and waits up to 900 seconds
(`--timeout N`); the run keeps going if it stops waiting, and
`vatio eval --show RUN_ID` prints it later. It can take a few minutes, so in
Claude Code run it in the background and read the report when it finishes. A
report that passes on live and fails on preview is listed first, as a
regression: [`vatio publish`](https://vatio.ai/docs/cli/deploy#the-publish-gate) refuses it.
Replays call the environment's tools for real. The whole loop, and what
resolves a report, is in [Improve your agent from flags](https://vatio.ai/docs/improve).

When neither the Vatio plugin for Claude Code nor the skills that
`npx @vatio-ai/skills` copies into a project are installed, `vatio flags` and
`vatio eval` end with a line on stderr saying so: without them Claude Code has
no `/vatio:vatio-improve`. They also say when the plugin or the copy is older
than the published skills, and how to update it
([Claude Code](https://vatio.ai/docs/integrations/claude-code#keeping-them-up-to-date)). It shows at
most once a day per directory, never in CI, and `VATIO_NO_SKILLS_HINT=1` turns
it off.

#### Tool metrics

A tool that fails seldom gets reported: the agent apologizes and moves on.
`vatio tools` is how your coding agent finds those, with the same numbers as
the inbox's [Tools page](https://vatio.ai/docs/tools#how-your-backend-is-answering):

```bash
vatio tools --env live                 # the last 7 days on live
vatio tools --env live --period 1h     # did the fix you just published stop the errors?
vatio tools --env live --json          # the same, for a script
```

It defaults to `live` and to the last 7 days. The periods are `1h`, `1d`,
`7d` and `30d`. `--env preview` only has the calls its own chats and eval
replays made; visitors' calls are on live.

The markdown starts with how to work it, like `vatio flags`, then:

- **By tool:** every tool the environment declares, never-called ones
  included, sorted by failed calls: calls, failed calls, error rate,
  timeouts, p50 and p95 latency, and the last failure.
- **Calls that failed:** the 50 most recent, each with the request
  (method and URL, without the query), HTTP status, outcome, duration, the
  `error_key` your backend returned, what your backend answered, and the
  `vatio chat show CHAT_ID` that prints the conversation.

A call failed when it ended any way but `ok`: `http_error` (a status other
than 2xx), `timeout` (8 seconds), `connection_error`, or `invalid_response`
(a 2xx that was not JSON). What your backend answered is kept for 30 days,
encrypted and cut to 4 KB, and can carry visitors' data: keep it out of
commits, pull requests and issues.

`--json` prints:

```json
{
  "environment": "live",
  "period": "7d",
  "since": "2026-10-02T14:00:00Z",
  "console_url": "https://inbox.vatio.ai/acme/tools?period=7d",
  "tools": [
    {
      "tool": "check_stock",
      "calls": 412,
      "failed": 9,
      "error_rate": 0.0218,
      "timeouts": 2,
      "p50_ms": 180,
      "p95_ms": 2400,
      "last_failure_at": "2026-10-09T13:12:44Z"
    }
  ],
  "failed_calls": [
    {
      "tool": "check_stock",
      "at": "2026-10-09T13:12:44Z",
      "outcome": "http_error",
      "http_status": 500,
      "error_key": null,
      "duration_ms": 312,
      "request_method": "GET",
      "request_url": "https://api.acme.com/stock",
      "response_body": "{\"error\":\"…\"}",
      "chat_id": 1234
    }
  ]
}
```

`error_rate`, `p50_ms`, `p95_ms` and `last_failure_at` are `null` for a tool
with no calls, or no failures, in the period.

### Knowledge bases

`https://vatio.ai/docs/cli/knowledge`

| Command | Purpose |
|---|---|
| `vatio kb [list]` | List knowledge bases and references |
| `vatio kb show NAME` | Inspect a base and its entries |
| `vatio kb create NAME` / `vatio kb rm NAME` | Create or delete an unreferenced base |
| `vatio kb write BASE ENTRY [FILE]` | Write an entry, from a file or stdin |
| `vatio kb cat BASE ENTRY [--version N]` | Print an entry's stored Markdown, or one of its versions |
| `vatio kb rm-entry BASE ENTRY` | Delete one entry |
| `vatio kb status BASE` | What this environment changed that live does not have, and any conflicts |
| `vatio kb publish BASE` | Take this environment's changes to the base live, without the agent |
| `vatio kb history BASE` | Every version that reached live, newest first |

`write` reads stdin when no file is given, which is what makes the edit loop
one line:

```bash
vatio kb cat docs refunds | edit | vatio kb write docs refunds
```

`show` is the one that answers "why is it not answering from this": it reports
which entries are ready to be searched.

**Entry commands work in one environment.** `show`, `write`, `cat`,
`rm-entry`, `status` and `publish` take `--env preview` or `--env live`, and
default to `preview`. A write there is answered from in preview only, and goes
live with `vatio publish`. `--env live` reads and writes live directly. See
[Knowledge bases](https://vatio.ai/docs/knowledge).

### Business

`https://vatio.ai/docs/cli/business`

| Command | Purpose |
|---|---|
| `vatio business` | Name, description, brand, opening hours (and whether it is open now), and the agent's handoff rules |
| `vatio business set [--name NAME] [--description TEXT \| --description-file FILE]` | Change the name or the description; `--description-file -` reads stdin |
| `vatio business set [--about TEXT] [--color #RRGGBB \| --color default] [--logo FILE \| --remove-logo]` | Change the brand: the visitor-facing about, the widget's accent color, and the logo (PNG, JPG, WebP or GIF, up to 2 MB) |
| `vatio business hours --timezone ZONE --mon 09:00-18:00 --tue 09:00-13:00,14:00-18:00 ...` | Replace the opening hours |
| `vatio business hours --clear` | No hours: someone is always available |

This is the same business the [inbox's Business page](https://vatio.ai/docs/channels/inbox#business)
edits: one value for every environment, live as soon as it is saved, and not
part of `vatio.yml`. It is usually left to whoever runs the business; the
command is for setting a workspace up from a terminal.

`hours` replaces the whole week. Each day takes one or more 24h ranges
separated by commas, and a day you do not pass is a day nobody is on duty.
Without `--timezone` it keeps the current one, or uses this machine's on the
first schedule. See [Business hours](https://vatio.ai/docs/handoff#business-hours) for what the
hours decide.

```bash
vatio business set --name "Panadería Quillay" --description-file about.md
vatio business set --logo logo.png --color "#8A4B2A" --about "Pan de masa madre en Ñuñoa"
vatio business hours --timezone America/Santiago \
  --mon 09:00-13:00,14:00-18:00 --tue 09:00-18:00 --sat 10:00-14:00
```

The handoff rules are printed from the live agent and are read-only here: they
are declared under `handoff:` in [`vatio.yml`](https://vatio.ai/docs/handoff).

### Secrets

`https://vatio.ai/docs/cli/secrets`

| Command | Purpose |
|---|---|
| `vatio secrets list` / `set KEY [VALUE]` / `rm KEY` | Manage workspace secrets |
| `... --env NAME` | The value one environment uses instead |
| `... --tests` | The value preview, and every eval replay, uses instead |

Leave `VALUE` out and `set` asks for it with the input hidden, so the value
stays out of your shell history. Off a terminal it reads stdin:
`op read op://Vault/item/field | vatio secrets set KEY`.

Keys use uppercase letters, digits and underscores, starting with a letter.
Keys starting with `VATIO_`, and `DISPATCHER`, are reserved for Vatio. Tools
read values with `$env.KEY`.

Without `--env` a secret is the value everywhere. With `--env preview` or
`--env live`, it is the value only that environment uses, replacing the one
everywhere else. `set` and `rm` print which one they touched:
`(every environment)`, `(only preview)` or `(tests)`.

`list` shows every secret of the workspace, each environment's own included,
with its scope on the same line. `list --env NAME` shows only what that
environment reads.

With `--tests` it is the test value: what preview uses, and every eval replay,
live's included. A staging URL set once covers all of them; `list` marks it
`(tests)`. `--tests` and `--env` do not go together.

`list` returns names, never values. Changes take effect on subsequent tool
calls without deploying — a rollback does not restore an old value. See
[Secrets](https://vatio.ai/docs/deployments#secrets).

Never put a private signing key in here: that store is read by Vatio, and the
whole point of [authentication](https://vatio.ai/docs/authentication/) is that Vatio holds only your
public key.

### Publishable tokens

`https://vatio.ai/docs/cli/tokens`

| Command | Purpose |
|---|---|
| `vatio tokens list` / `create [--env NAME] [--label NAME] [--origin URL]...` / `revoke PREFIX` | Manage publishable tokens; `create` defaults to `live` |
| `vatio tokens origins PREFIX URL...` | Replace a token's allowed origins; with no URL, clear them |

`create` prints the full token once. `list` shows prefixes and each token's
allowed origins; the prefix is also what `origins` and `revoke` take.

A token works only from its own allowed origins, plus Vatio's own pages and the
[app builders](https://vatio.ai/docs/channels/widget) (Lovable, Replit, Bolt, v0, Base44); with
none, it works only there. `--origin` is repeatable. Origins are exact
`scheme://host[:port]` values, `ios-app://BUNDLE_ID` for an iOS app, or
`android-app://PACKAGE_NAME` for an Android app. The
token in the install snippet the inbox shows is managed by Vatio and is not
listed here: edit its origins in the tokens section of **Channels → Web widget**
in the inbox.

Revoking prevents new chats and conversation lists with that token; chat
credentials already issued stay valid until they expire.

`vatio env` prints an environment's widget snippet with its token. The
widget's look is edited in **Channels → Web widget** in the inbox; see
[Web widget](https://vatio.ai/docs/channels/widget) for the fields themselves.

Never use a developer token (`vat_…`) in a browser. Publishable tokens are the
ones meant for page source, and each token's allowed origins are what scope it.

### Server keys

`https://vatio.ai/docs/cli/keys`

| Command | Purpose |
|---|---|
| `vatio keys list` | Active keys: prefix, environment, label, last use |
| `vatio keys create [--env NAME] [--label NAME]` | Create a key; defaults to `live` |
| `vatio keys revoke PREFIX` | Revoke a key |

`create` prints the full key once. Vatio keeps only a digest of it.

A server key is what your own backend calls the [server API](https://vatio.ai/docs/api/messages)
with. It belongs to the workspace and one environment. It is not your CLI token
(`vat_…`), which is yours and is replaced at every `vatio login`. It is not a
publishable token either, which is public by design. Keep a server key on a
server: it can message your customers.

A preview key can only write to your own test phones, which makes it the way to
[try the API from your phone](https://vatio.ai/docs/api/messages#try-it-from-your-phone).

### Templates and webhooks

`https://vatio.ai/docs/cli/webhooks`

| Command | Purpose |
|---|---|
| `vatio templates list [--env live] [--json]` | The number's templates, their parameter count and the `template` object to send |
| `vatio templates create NAME --language CODE --body TEXT [--example VALUE]... [--category utility\|marketing] [--footer TEXT] [--json]` | Submit a template for Meta's review |
| `vatio webhooks list` / `create URL [--event NAME]... [--label NAME]` / `test ID` / `deliveries ID` / `enable ID` / `rm ID` | Where Vatio tells your backend what happened |

Your backend sends WhatsApp messages through the [server API](https://vatio.ai/docs/api/messages)
with a [server key](https://vatio.ai/docs/cli/keys); the CLI does not send them. These commands
set up what that backend needs.

#### Templates

WhatsApp only delivers an approved template to someone who has not written in
the last 24 hours. Until you connect a number of your own, `templates create`
puts a template on Vatio's test number so you can try it: once Meta approves
it, a preview key sends it to your test phones. When you connect your own
number (`vatio whatsapp` prints where), Vatio moves your templates to it under
the same names, Meta reviews them again there, and they are removed from
Vatio's test number. From then on your backend sends from your number with a
live key, and `templates create` submits straight to it.

```sh
vatio templates create order_shipped --language es \
  --body "Hola {{1}}, tu pedido {{2}} va en camino." --example Ana --example "#1042"
```

- **`NAME`**: lowercase letters, digits and underscores.
- **`--language`**: Meta's code for it, like `es`, `en_US` or `pt_BR`.
- **`--body`**: up to 1024 characters. Placeholders are `{{1}}`, `{{2}}`, … in
  order, and the body cannot start or end with one.
- **`--example`**: one per placeholder, in order. Meta reviews the template with
  them filled in.
- **`--category`**: `utility` (the default) for updates someone expects, like
  a confirmation or a reminder; `marketing` for anything promotional. Meta may
  move a template to the category it thinks fits.

The template starts `PENDING`. Meta usually decides within minutes and at
most in 24 hours. `templates list --env preview` shows where it is on Vatio's
test number and `templates list` on your own, with the reason when it is
`REJECTED`. Each approved template comes with the `template` object to paste
into a request, with as many `params` as its body takes. The inbox's
**Outreach → Templates** shows the same, for whichever number your backend
sends from, with a button to copy that object. A workspace keeps up
to 20 templates on Vatio's test number; a rejected one is not moved to your
own.

#### Webhooks

`webhooks create` prints the signing secret once. `webhooks test` sends a
`ping` and shows how your endpoint answered, and `webhooks deliveries` lists
the last 50 deliveries with their status and errors. See
[Messages](https://vatio.ai/docs/api/messages#webhooks) for the events and the signature.

### Routines

`https://vatio.ai/docs/cli/routines`

| Command | Purpose |
|---|---|
| `vatio routines list [--json]` | The workspace's routines, their numbers and whether they are on |
| `vatio routines create NAME --url URL --phone NUMBER [--phone NUMBER]... [--env NAME]` | Connect a routine; asks for its API token |
| `vatio routines update ID [--name NAME] [--url URL] [--phone NUMBER]... [--new-token]` | Change it; `--phone` replaces the list |
| `vatio routines test ID` | Start it with a `ping`, and show how the trigger answered |
| `vatio routines runs ID [--json]` | The last 50 runs, with each Claude session's link |
| `vatio routines enable ID` | Turn back on a routine Vatio switched off after 10 failed runs |
| `vatio routines rm ID` | Remove it |

A routine does nothing until the agent has it as a tool: `tools/NAME.yml`
with `routine: NAME`, and `NAME` in `agent.tools`. See
[Claude Code routines](https://vatio.ai/docs/integrations/claude-routines) for the tool file, what
the routine receives and how it reads the conversation.

#### Create

```sh
vatio routines create encargos \
  --url https://api.anthropic.com/v1/claude_code/routines/trig_…/fire \
  --phone +56912345678
```

- **`NAME`**: lowercase letters, digits, `-` and `_`. The tool file names it
  with `routine:`, and the routine receives it as `routine`.
- **`--url`**: the URL claude.ai shows for the routine's API trigger. Vatio
  only calls `api.anthropic.com`.
- **The token** is asked for rather than passed as a flag, so it stays out of
  your shell history; pipe it in to script it:
  `op read "op://Vault/Routine/token" | vatio routines create …`. Vatio
  refuses anything that is not a `sk-ant-oat01-…` token, keeps it encrypted
  and never shows it again.
- **`--phone`**: a number in international format, once per number, up to 20.
  Only WhatsApp conversations with these numbers get the tool.
- **`--env`**: `live` by default; a preview routine serves your test phones on
  Vatio's test number.

### Channels

`https://vatio.ai/docs/cli/channels`

| Command | Purpose |
|---|---|
| `vatio whatsapp` | Print the link to WhatsApp in the inbox: the **WhatsApp** tab of the **Test** page before you publish, **Channels → WhatsApp** after. With your own number connected, also whether it is live and who the agent answers |
| `vatio whatsapp only NUMBER [NUMBER]...` | The agent answers only these numbers on your own number; everyone else's messages still reach the inbox |
| `vatio whatsapp everyone` | The agent answers everyone again |
| `vatio instagram` | The same, for Instagram |

WhatsApp and Instagram are set up in the inbox, not the CLI: connecting your
own number or account goes through Meta's consent screen, and a test phone or
test account proves it is yours by sending Vatio a code. The test phones on
the shared WhatsApp preview number and the Instagram test accounts are in the
**WhatsApp** and **Instagram** tabs of the **Test** page. Your own number and
account are connected in **Channels**, once you publish.
`vatio status` has both links, `whatsapp_url` and `instagram_url`, and
`published` says which page they lead to.

See [WhatsApp](https://vatio.ai/docs/channels/whatsapp) and [Instagram](https://vatio.ai/docs/channels/instagram) for the
steps.

#### Who the agent answers

`vatio whatsapp only` replaces the list, so name every number each time, in
international format. The numbers of your
[routines](https://vatio.ai/docs/integrations/claude-routines) always count. **Channels → WhatsApp** in the inbox
shows and edits the same list, next to the **Live** switch; set it before
you switch **Live** on. See [Who the agent answers](https://vatio.ai/docs/channels/whatsapp#who-the-agent-answers).

### Authentication keys

`https://vatio.ai/docs/cli/auth`

| Command | Purpose |
|---|---|
| `vatio auth --new-key` | Generate the keypair that signs your JWT, and print the `auth:` block and claims to use |
| `vatio auth --supabase [URL] [--sign-in-url URL]` | Your users sign in with Supabase: write `auth: supabase:` into `vatio.yml` and set the `SUPABASE_URL` and `SUPABASE_ANON_KEY` secrets |

It writes two files, and they go to different places. `identity.pub` stays in
the workspace and is committed like any other file; `identity.pem` goes into
your own backend as a secret, and should be deleted from the workspace
directory once it is loaded there.

The command prints the `auth:` block to paste into `vatio.yml` and the claims
to sign, so the output is most of the setup. [Authentication](https://vatio.ai/docs/authentication/)
has the rest, including why `aud` is required and what a private tool sees.

`--supabase` needs no keys. Without a URL it finds the project in a `.env` or
in `src/integrations/supabase/client.ts`, checks that the project signs with
keys Vatio can verify, and keeps the rest of `vatio.yml` as it was.
`--sign-in-url` is your login page, for WhatsApp and Instagram. Replacing an
existing `public_key` needs `--force`, and `--no-secrets` leaves the secrets
alone. See [Supabase](https://vatio.ai/docs/integrations/supabase).

### Configuration and uploads

`https://vatio.ai/docs/cli/configuration`

`~/.vatio/config.json` stores `base_url` and `token`. Environment variables
override saved settings: `VATIO_BASE_URL`, `VATIO_TOKEN`, and `VATIO_HOME`
(the configuration directory). `config show` can print the saved token;
use `doctor` when sharing diagnostics.

After a command, the CLI says when npm has a newer release. It checks at most
once a day, only on a terminal, never in CI. `VATIO_NO_UPDATE_NOTIFIER=1` turns
it off.

`diff` and `push` send workspace files to the platform for validation. They
require network access and authentication. `push` creates the remote if
missing; `diff` requires an existing workspace.

Uploads exclude hidden files and directories, `node_modules`, `tmp`, and `log`.
Limits are 500 files, 2 MB per file, and 8 MB total. These exclusions do not
make an ordinary file safe to store credentials in.

## SDK

### Browser SDK

`https://vatio.ai/docs/sdk/`

The browser SDK is an npm package with no dependencies, and types included.
Create a publishable token and allow your page's origin as described in
[the widget guide](https://vatio.ai/docs/channels/widget).

```sh
npm install @vatio-ai/sdk
```

```js
import { Vatio } from "@vatio-ai/sdk";

const options = { workspace: "acme", token: "vatpub_REPLACE_ME" };
const chat = await Vatio.chat(options);
const messages = new Map();

function receive(message) {
  messages.set(message.id, message);
  // Render messages ordered by ID in your UI, escaping untrusted content.
}

chat.on("message", receive);
chat.on("typing", (typing) => { /* Update your typing indicator. */ });
chat.on("status", (status) => { /* Update your connection indicator. */ });
chat.on("error", (error) => console.error(error.code, error.message));

for (const message of await chat.history()) receive(message);
await chat.send("What does Acme do?");
// Call chat.close() when this view unmounts.
```

#### Without a bundler

A classic script from Vatio's CDN leaves the SDK on `window.Vatio`:

```html
<script src="https://cdn.vatio.ai/v1/sdk.js"></script>
<script>
  Vatio.chat({ workspace: "acme", token: "vatpub_REPLACE_ME" }).then((chat) => {
    chat.on("message", (message) => { /* render it */ });
  });
</script>
```

It serves the version the widget ships with, so it moves when the widget does.
To choose when you upgrade, pin a version the ordinary way —
`"@vatio-ai/sdk": "^3.0.0"` from npm, or the module from any npm CDN:

```html
<script type="module">
  import { Vatio } from "https://cdn.jsdelivr.net/npm/@vatio-ai/sdk@3/+esm";
</script>
```

Next: the [reference](https://vatio.ai/docs/sdk/reference) for every method and option, and the
[limits](https://vatio.ai/docs/sdk/limits) every page hits.

### SDK reference

`https://vatio.ai/docs/sdk/reference`

`Vatio.chat` starts or resumes a conversation and waits for a subscription
before resolving. The SDK handles reconnects, history resync, and polling
fallback. Messages include `id`, `role`, and `content`. Deduplicate history
and event delivery by `id`.

| Method | Purpose |
|---|---|
| `Vatio.config({ workspace, token })` | Fetch public agent and widget configuration |
| `Vatio.chat(options)` | Start, resume, or reopen a conversation |
| `Vatio.conversations({ workspace, token, ... })` | List this browser visitor's conversations, newest first |
| `Vatio.resumable({ workspace, ... })` | Check for a locally stored, unexpired conversation |
| `Vatio.reset({ workspace, ... })` | Forget the locally stored conversation; does not delete server history |
| `chat.send(text, { files })` | Send a message, optionally with files |
| `chat.history({ limit })` | Fetch messages |
| `chat.on(event, handler)` | Subscribe to `message`, `typing`, `status`, `error`, `feedback`, or `update` |
| `chat.feedback()` | Fetch the feedback moment on offer, or `null` |
| `chat.rate(rating, { comment })` | Answer it: `good`, `neutral`, or `bad` |
| `chat.dismissFeedback()` | Decline it |
| `chat.close()` | Disconnect the client |

#### Files and voice notes

Pass `files` — `File` or `Blob` objects, up to four — to attach images (JPEG,
PNG, WebP, GIF, HEIC), PDFs, text files (plain, CSV, Markdown) or audio (M4A,
MP3, OGG, WAV, AAC, FLAC). With files, `text` may be empty:

```js
const input = document.querySelector("input[type=file]");
const { message } = await chat.send("Is this the right part?", { files: [...input.files] });
```

`send` resolves to the stored message. A voice note is transcribed in the
background: the stored message comes back as "Audio", and a few seconds later
an `update` event — `{ id, content, content_is_media_label }` — carries the
transcript for the message with that `id`; merge it in. The agent answers only
once the transcript is in. A PDF holds the reply the same way while it is read,
and what it says stays out of `content`. Messages carry `attachments` —
`{ id, filename, content_type, byte_size, kind, url }`, where `kind` is
`image`, `audio`, `video` or `file`, and `url` is absolute and loads without
the chat credential. When the visitor sent only a file, `content` is a stand-in
such as "Image" and `content_is_media_label` is `true`: draw the file instead.

To record in a browser, ask `MediaRecorder` for `audio/mp4` (or `audio/ogg`)
explicitly. Chrome defaults to WebM, which is not accepted.

#### Visitor feedback

Vatio offers a feedback moment at most once per conversation, when a request
looks resolved and the visitor closed the topic — see
[the widget guide](https://vatio.ai/docs/channels/widget#visitor-feedback). The `feedback` event
delivers it, including one offered before the page connected:

```js
chat.on("feedback", (feedback) => {
  // { agentName, messageId, question, rating, submittedAt, dismissed }, or null
  if (feedback && !feedback.rating && !feedback.dismissed) {
    showRating(feedback.question ?? `How did ${feedback.agentName} do?`);
  }
  else hideRating();
});

await chat.rate("neutral", { comment: "Took a while" });
```

`question` is how the agent asks, written for that conversation; it is `null`
when there is none, so keep your own wording as the fallback.

The event fires again with `null` when the visitor writes without answering,
because the moment has passed. `comment` takes up to 2,000 characters and is
meant for `neutral` and `bad`. `rate` rejects with `feedback_unavailable` once
the moment is gone.

#### Options

`Vatio.chat` accepts `workspace` and `token`, plus optional `baseUrl`, `fresh`,
`scope`, `visitorToken`, `replyStyle`, and `conversation`. `fresh: true` starts
a new chat. `conversation` reopens an entry returned by `Vatio.conversations`.

Use a distinct `scope` when preview and live UIs share an origin. Pass the
same `scope` and `visitorToken` to `chat`, `conversations`, `resumable`, and
`reset`. Storage is separated by the complete visitor token, so issuing a new
token also changes its local conversation scope. It does not merge contacts
across devices.

#### Storage

The SDK stores the current conversation per tab and a visitor reference in
local storage. Chat credentials last 12 hours. Treat visitor references and
chat credentials as private: the reference can retrieve that visitor's
anonymous conversations and fresh chat credentials, so store the reference
where the signed-in user cannot read another's, and never derive it from a
public or predictable user ID. Issue a new reference when the signed-in user
changes.

A conversation the visitor had signed in is listed only when the request also
carries their signed token. `Vatio.conversations` sends the `visitorToken` you
pass it in the `Vatio-Visitor-Token` header (SDK 3.5.1 and later; iOS 0.4.1,
Android 0.2.1). Without it, the list holds only what the visitor said
anonymously, so a reference copied out of a browser does not open a signed-in
customer's history.

### Limits and delivery

`https://vatio.ai/docs/sdk/limits`

#### Limits

Messages are limited to 4,000 characters, and to four files of up to 8 MB
each. A file's type is read from its bytes, not from the name or the type the
client declared, and a type the agent cannot read is refused with
`unsupported_file_type` rather than stored. Per browser IP and workspace, per
minute: 10 conversations started, 30 messages sent, 60 configuration reads, and
60 conversation lists. Over the limit the SDK surfaces an `error` event and the
underlying request answers HTTP 429; back off rather than retrying immediately.

Every request the SDK makes is checked against the allowed origins of the
publishable token it uses (calls on an open conversation, against those of any
active token of the workspace), so a token lifted from your HTML is useless
from another page. Those origins are what keep a token on your own site; they are not evidence
of who the visitor is. To tell the agent who is signed in, see
[Sessions and channels](https://vatio.ai/docs/authentication/sessions).

#### Reply style

| `replyStyle` | Delivery |
|---|---|
| `stream` | Default on web: one complete reply, without artificial delay |
| `paced` | Short messages with typing indicators and pauses |
| `instant` | One complete reply with no typing events |

`stream` is not token-by-token network streaming. Render the received reply
progressively if desired. The style is fixed at chat creation; use `fresh: true`
when changing it.

Use the SDK for realtime delivery. The underlying socket protocol is not a
public integration contract.

### iOS SDK

`https://vatio.ai/docs/sdk/ios`

A Swift package with no dependencies and no UI. It keeps the conversation —
messages, typing, connection — as observable state, and you draw it however
your app looks. iOS 15 or later.

#### Setup

Create a publishable token that allows your app. Its origin is `ios-app://`
plus its bundle id:

```bash
vatio tokens create --env live --label ios --origin ios-app://com.acme.app
```

To add it to an existing token instead, use `vatio tokens origins PREFIX URL...`
or the tokens section of **Channels → Web widget** in the inbox.

Add the package `https://github.com/vatio-ai/ios` in Xcode (File → Add
Package Dependencies…), then create a client with the `vatpub_` token. It is
meant to ship inside the app; a `vat_` token must never.

```swift
import Vatio

let vatio = Vatio(workspace: "acme", token: "vatpub_REPLACE_ME")
let chat = VatioChat(vatio)
```

#### Using it

```swift
chat.messages        // [VatioMessage], oldest first: id, role, content, createdAt, isPending
chat.isTyping        // the agent is writing
chat.status          // .idle, .connecting, .connected, .reconnecting, .closed

try await chat.send("What does Acme sell?")
```

`VatioChat` is an `ObservableObject`, so a SwiftUI view that observes it
redraws as the reply arrives. Creating one opens nothing: the conversation
starts on the first `send`, and the visitor's message is in `messages` at once,
with `isPending` set until the server confirms it. Call `await chat.resume()`
when the view appears to reopen the visitor's conversation, if they have one.

A common layout is a text bar at the bottom of a screen that, on submit,
switches to a conversation tab and sends:

```swift
AskBar { question in
    tab = .conversation
    Task { try? await chat.send(question) }
}
```

The [package README](https://github.com/vatio-ai/ios#readme) has the
complete SwiftUI example.

| Method | Purpose |
|---|---|
| `vatio.config()` | Agent name, avatar, accent color, greeting and suggestions |
| `vatio.conversations(visitorToken:)` | This visitor's conversations on this device, newest first |
| `chat.send(_:attachments:)` | Send a message, optionally with files; starts the conversation on first call |
| `chat.resume()` | Reopen the stored conversation; never starts a new one |
| `chat.open(_:)` | Switch to a conversation from `conversations()` |
| `chat.startOver()` | Forget the stored conversation; server history is kept |
| `chat.rate(_:comment:)` | Answer the feedback moment: `.good`, `.neutral`, `.bad` |
| `chat.dismissFeedback()` | Decline the feedback moment |
| `chat.close()` | Disconnect |

`send` throws a `VatioError` (`code`, `message`, `status`) and removes the
pending message, so put the text back in your field. Failures outside a
`send` land in `chat.lastError`.

#### Files and voice notes

Attach up to four `VatioUpload`s — images, PDFs, text files or audio, up to
8 MB each, as on the [web](https://vatio.ai/docs/sdk/reference#files-and-voice-notes). With files,
the text may be empty:

```swift
try await chat.send(attachments: [VatioUpload(data: jpeg, filename: "photo.jpg", contentType: "image/jpeg")])
try await chat.send(attachments: [try VatioUpload(fileURL: recordingURL)])  // an AVAudioRecorder .m4a
```

Once the server answers, the pending message is replaced by the stored one,
and each `VatioAttachment` has a `url`. A voice note is transcribed in the
background: the message reads "Audio" at first, and its `content` becomes the
transcript a few seconds later, in place; the agent answers after that. When `isMediaLabel` is true, `content` is only a
stand-in ("Image") — draw the attachment instead. The example app has a
composer with a photo picker and a voice recorder.

#### Visitor feedback

When a request looks resolved, Vatio offers a feedback moment, at most once
per conversation (see [the widget guide](https://vatio.ai/docs/channels/widget#visitor-feedback)).
`chat.feedback` holds it; show your own prompt while `isOpen`:

```swift
if let feedback = chat.feedback, feedback.isOpen {
    RatingCard(question: feedback.question ?? "How did \(feedback.agentName) do?") { rating, comment in
        Task { try await chat.rate(rating, comment: comment) }
    }
}
```

`question` is how the agent asks, written for that conversation, or `nil`
when there is none. It goes back to `nil` when the visitor writes again
without answering.

#### Signed-in users

Pass a visitor token your backend signed to say who the user is, as described
in [Sessions and channels](https://vatio.ai/docs/authentication/sessions):

```swift
let chat = VatioChat(vatio, visitorToken: signedToken)
```

A different token subject is a different person with their own conversation,
so signing out of your app signs out of the chat. An anonymous conversation is
kept when the user signs in.

#### Delivery and storage

Replies arrive over a WebSocket. When it drops, the SDK reconnects, polls in
the meantime, and fetches what it missed; messages are deduplicated by id.
`replyStyle` (`.stream` by default, `.paced`, `.instant`) works as on the
[web](https://vatio.ai/docs/sdk/limits#reply-style).

The conversation and the visitor id are stored in `UserDefaults`, per
workspace and signed-in user. Chat credentials last 12 hours. Rate limits
and message length are the [same as on the web](https://vatio.ai/docs/sdk/limits).

### Android SDK

`https://vatio.ai/docs/sdk/android`

A Kotlin library with no UI. It keeps the conversation — messages, typing,
connection — as `StateFlow`s, and you draw it however your app looks, in
Compose or Views. Android 8.0 (API 26) or later; its only dependencies are
OkHttp and kotlinx.coroutines.

#### Setup

Create a publishable token that allows your app. Its origin is
`android-app://` plus its package name:

```bash
vatio tokens create --env live --label android --origin android-app://com.acme.app
```

To add it to an existing token instead, use `vatio tokens origins PREFIX URL...`
or the tokens section of **Channels → Web widget** in the inbox.

The library is built by JitPack from
[github.com/vatio-ai/android](https://github.com/vatio-ai/android). Add the
repository and the dependency:

```kotlin
// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven("https://jitpack.io")
    }
}

// app/build.gradle.kts
dependencies {
    implementation("com.github.vatio-ai:android:0.2.1")
}
```

Then create a client with the `vatpub_` token. It is meant to ship inside the
app; a `vat_` token must never.

```kotlin
import ai.vatio.Vatio
import ai.vatio.VatioChat

val vatio = Vatio(context, workspace = "acme", token = "vatpub_REPLACE_ME")
val chat = VatioChat(vatio)
```

#### Using it

```kotlin
chat.messages        // StateFlow<List<VatioMessage>>, oldest first: id, role, content, createdAt, isPending
chat.isTyping        // the agent is writing
chat.status          // IDLE, CONNECTING, CONNECTED, RECONNECTING, CLOSED

chat.send("What does Acme sell?")   // suspends
```

Collect the flows (`collectAsStateWithLifecycle()` in Compose) and the UI
redraws as the reply arrives. Creating a chat opens nothing: the conversation
starts on the first `send`, and the visitor's message is in `messages` at once,
with `isPending` set until the server confirms it. Call `chat.resume()` when
the screen appears to reopen the visitor's conversation, if they have one.
Keep the chat in a `ViewModel` and call `chat.close()` from `onCleared()`.

A common layout is a text bar at the bottom of a screen that, on submit,
switches to a conversation tab and sends:

```kotlin
AskBar { question ->
    tab = Tab.Conversation
    viewModelScope.launch { chat.send(question) }
}
```

The [library README](https://github.com/vatio-ai/android#readme) has the
complete Compose example.

| Method | Purpose |
|---|---|
| `vatio.config()` | Agent name, avatar, accent color, greeting and suggestions |
| `vatio.conversations(visitorToken)` | This visitor's conversations on this device, newest first |
| `chat.send(text, attachments)` | Send a message, optionally with files; starts the conversation on first call |
| `chat.resume()` | Reopen the stored conversation; never starts a new one |
| `chat.open(conversation)` | Switch to a conversation from `conversations()` |
| `chat.startOver()` | Forget the stored conversation; server history is kept |
| `chat.rate(rating, comment)` | Answer the feedback moment: `GOOD`, `NEUTRAL`, `BAD` |
| `chat.dismissFeedback()` | Decline the feedback moment |
| `chat.close()` | Disconnect |

`send` throws a `VatioException` (`code`, `message`, `status`) and removes the
pending message, so put the text back in your field. Failures outside a
`send` land in `chat.lastError`.

#### Files and voice notes

Attach up to four `VatioUpload`s — images, PDFs, text files or audio, up to
8 MB each, as on the [web](https://vatio.ai/docs/sdk/reference#files-and-voice-notes). With files,
the text may be empty:

```kotlin
chat.send(attachments = listOf(VatioUpload.fromUri(context, pickedUri)))   // off the main thread: it reads the file
chat.send(attachments = listOf(VatioUpload.fromFile(recording)))           // a MediaRecorder .m4a
```

Once the server answers, the pending message is replaced by the stored one,
and each `VatioAttachment` has a `url`. A voice note is transcribed in the
background: the message reads "Audio" at first, and its `content` becomes the
transcript a few seconds later, in place; the agent answers after that. When
`isMediaLabel` is true, `content` is only a stand-in ("Image") — draw the
attachment instead. The example app has a composer with a photo picker and a
voice recorder.

#### Visitor feedback

When a request looks resolved, Vatio offers a feedback moment, at most once
per conversation (see [the widget guide](https://vatio.ai/docs/channels/widget#visitor-feedback)).
`chat.feedback` holds it; show your own prompt while `isOpen`:

```kotlin
val feedback by chat.feedback.collectAsStateWithLifecycle()
feedback?.takeIf { it.isOpen }?.let {
    RatingCard(question = it.question ?: "How did ${it.agentName} do?") { rating, comment ->
        scope.launch { chat.rate(rating, comment) }
    }
}
```

`question` is how the agent asks, written for that conversation, or `null`
when there is none. It goes back to `null` when the visitor writes again
without answering.

#### Signed-in users

Pass a visitor token your backend signed to say who the user is, as described
in [Sessions and channels](https://vatio.ai/docs/authentication/sessions):

```kotlin
val chat = VatioChat(vatio, visitorToken = signedToken)
```

A different token subject is a different person with their own conversation,
so signing out of your app signs out of the chat. An anonymous conversation is
kept when the user signs in.

#### Delivery and storage

Replies arrive over a WebSocket. When it drops, the SDK reconnects, polls in
the meantime, and fetches what it missed; messages are deduplicated by id.
`replyStyle` (`STREAM` by default, `PACED`, `INSTANT`) works as on the
[web](https://vatio.ai/docs/sdk/limits#reply-style).

The conversation and the visitor id are stored in `SharedPreferences`, per
workspace and signed-in user. Chat credentials last 12 hours. Rate limits
and message length are the [same as on the web](https://vatio.ai/docs/sdk/limits).

### Ruby gem

`https://vatio.ai/docs/sdk/ruby`

`vatio-sdk` is the server side of a Vatio integration in Ruby and Rails.
It does two separate jobs, in two separate requires:

| Require | What it does | Talks to Vatio? |
|---|---|---|
| `vatio/identity` | Signs the JWT that tells your agent who the user is, and verifies it when a [private tool](https://vatio.ai/docs/authentication/tools) calls your API | Never: signing and verifying are local |
| `vatio/server` | A client for the [server API](https://vatio.ai/docs/api/messages), so the agent writes to someone first on WhatsApp, plus the receiver for its webhooks | Yes, only when you call it |

An app that only signs tokens never loads the second.

```ruby
gem "vatio-sdk", "~> 0.6"
```

It is on [RubyGems](https://rubygems.org/gems/vatio-sdk) from 0.6.0, and its only
dependency is the `jwt` gem. Until then it was called `vatio-identity` and
installed from git; a Gemfile that still says
`gem "vatio-identity", git: "https://github.com/vatio-ai/ruby"` keeps working.
The source and the full README are at
[github.com/vatio-ai/ruby](https://github.com/vatio-ai/ruby).

#### Setup

The keypair comes from the CLI: [`vatio auth --new-key`](https://vatio.ai/docs/cli/auth) writes
`identity.pub` and `identity.pem` in the workspace, gitignores the private
half and prints the `auth:` block for `vatio.yml`. Vatio holds only the public
half, so it can check a token and never mint one.

```ruby
### config/initializers/vatio.rb
Vatio::Identity.configure do |c|
  c.audience    = "acme"                                # your workspace slug
  c.private_key = ENV.fetch("VATIO_IDENTITY_PRIVATE_KEY") # the PEM, not a path
end
```

| Setting | Default | |
|---|---|---|
| `audience` | none, required | Your workspace slug. Vatio refuses a token whose `aud` is not the slug |
| `private_key` | none | The PEM that signs. A public key here is refused |
| `public_key` | the private key | Only to verify without the private key |
| `algorithm` | from the key | `ES256`/`ES384`/`ES512` from the EC curve, `RS256` for RSA. Must match `auth.algorithm` in `vatio.yml` |
| `expires_in` | `3600` | Seconds a widget token lasts |

Read the key from an environment variable or your secrets store: a private key
in the repo is a private key that leaked.

#### Identify users on the web

```erb
<%= raw Vatio::Identity.widget_tag(
      workspace: "acme",
      token: "vatpub_...",
      subject: current_user&.id,
      claims: { name: current_user&.name, email: current_user&.email }) %>
```

It renders the [widget](https://vatio.ai/docs/channels/widget)'s script tag with a visitor token
signed for `subject`. With no `subject`, there's no token, and the agent
answers with its public tools only, which is right for a signed-out visitor.

`claims` become `$auth.claims` in your tools. `aud`, `sub`, `exp` and the other
registered claims are always set by the gem, never taken from `claims`. Add
`metadata` to show a profile link and badges to whoever answers in the inbox;
see [Sessions and channels](https://vatio.ai/docs/authentication/sessions).

`Vatio::Identity.token_for(subject:, claims:, expires_in:)` returns the bare
token, for the [browser SDK](https://vatio.ai/docs/sdk/) or `window.VatioWidget.identify(token)`.

::: warning Caching
Rails renders the tag per request, so it is safe by default. If a page cache, a
CDN or `caches_action` stores it, the next visitor gets this one's identity.
On a cached page, render the tag without a token and call
`window.VatioWidget.identify(token)` from an uncached endpoint.
:::

#### Sign in from WhatsApp and Instagram

A WhatsApp or Instagram chat has no page to put a token on, so when a private
tool needs one, Vatio sends the contact a **Sign in** button. It opens
`auth.sign_in_url` with a `code`; sign them in the way your site already does
and redirect:

```ruby
class VatioSignInsController < ApplicationController
  before_action :authenticate_user!   # your own login

  def show
    # Back from a Turbo login form: fetch cannot follow a redirect to vatio.ai.
    if request.headers["X-Turbo-Request-Id"]
      return render html: helpers.tag.meta(name: "turbo-visit-control", content: "reload")
    end

    redirect_to Vatio::Identity.sign_in_redirect_url(
      code: params[:code],
      subject: current_user.id,
      claims: { name: current_user.name, phone_number: current_user.phone }
    ), allow_other_host: true
  end
end
```

The token lasts a week by default (`expires_in:` changes it). The host is fixed
to `vatio.ai`, never taken from the request, and a `code` that is not a Vatio
sign-in code raises `ArgumentError`.

The first lines matter when the contact was not signed in yet: your login form
redirects back here once they are, and Turbo submits that form with `fetch`,
which cannot follow the next redirect to another site. The login page just sits
there. Answering Turbo with a reload makes the browser load this page for real,
and that redirect works. See
[On WhatsApp and Instagram](https://vatio.ai/docs/authentication/sessions#on-whatsapp).

#### Verify the token in your API

A [private tool](https://vatio.ai/docs/authentication/tools) forwards the same JWT to your endpoint
as `Authorization: Bearer`. Include the concern and scope by `vatio_subject`:

```ruby
class Api::BookingsController < ApplicationController
  include Vatio::Identity::Authentication
  before_action :authenticate_vatio!   # 401 unless the token verifies

  def index
    render json: Booking.where(user_id: vatio_subject)
  end
end
```

The check pins the algorithm (never read from the token's header) and requires
`sub`, `exp` and an `aud` equal to your slug, with 60 seconds of leeway. That
is the same check Vatio runs.

| Method | Returns |
|---|---|
| `vatio_subject` | The user id from `sub`, as a string, or `nil` |
| `vatio_principal` | `subject`, `claims` (without registered claims) and `token`, or `nil` |
| `vatio_signed_in?` | Whether the token verified |
| `authenticate_vatio!` | Renders `{"error":"unauthorized"}` with a `401` unless signed in |

Outside a controller, `Vatio::Identity.verify(token)` returns the same
principal, or `nil` for anything that does not verify. `nil` is not an error:
it means signed out.

#### Several workspaces

`configure` sets one workspace for the whole app. An app that serves several,
each customer with their own Vatio workspace and key, builds a `Config` per
workspace and passes it as `config:`:

```ruby
config = Vatio::Identity::Config.new(audience: tenant.vatio_slug, public_key: tenant.vatio_public_key)

Vatio::Identity.verify(token, config: config)
```

`token_for`, `widget_tag` and `sign_in_redirect_url` take `config:` too. They
sign, so their config needs `private_key:`. In a controller, override
`vatio_identity_config` to choose the workspace; returning `nil` for a tenant
without Vatio answers `401`:

```ruby
class Api::BookingsController < ApplicationController
  include Vatio::Identity::Authentication
  before_action :authenticate_vatio!

  private

  def vatio_identity_config
    return if current_tenant.vatio_public_key.blank?

    Vatio::Identity::Config.new(audience: current_tenant.vatio_slug, public_key: current_tenant.vatio_public_key)
  end
end
```

Choose the tenant from the request (the host, the path), never from the
token's own `aud`: the token is what is being checked.

#### Message people first

`require "vatio/server"` adds a client for the [Messages API](https://vatio.ai/docs/api/messages):
the agent writes to someone first on WhatsApp (an order that shipped, an
appointment tomorrow) and keeps answering when they reply.

```ruby
require "vatio/server"

Vatio::Server.configure do |c|
  c.server_key     = ENV.fetch("VATIO_SERVER_KEY")   # vsk_..., from `vatio keys create`
  c.workspace      = "acme"                           # defaults to Vatio::Identity's audience
  c.webhook_secret = ENV["VATIO_WEBHOOK_SECRET"]      # whsec_..., for the receiver
end

message = Vatio::Server.send_message(
  to: "+56912345678",
  brief: "Order #1042 shipped today with Starken, tracking 99812. Offer to send the tracking link.",
  external_ref: "order-1042-shipped",
  subject: user.id   # signs an identity, so private tools run when they answer
)
message.queued?   # => true
```

Create the key with [`vatio keys create`](https://vatio.ai/docs/cli/keys) and keep it on your
server: it can message your customers.

| Method | Purpose |
|---|---|
| `send_message(to:, brief:/text:/template:, external_ref:, ...)` | Queue a message. Returns a `Message` |
| `message_status(id)` | Read one back, with `content` once sent |
| `messages(external_ref:, status:, limit:, before:)` | List them, newest first |
| `templates` | The approved templates, each with `with_params(...)` |
| `identity_for(subject:, claims:, ttl:)` | Sign an identity once, to reuse on a retry |

`brief` has the agent write the message; `text` is sent as is; `template` is
what goes out when the 24-hour window is closed. `idempotency_key` defaults to
`external_ref`, so a retry is a replay and never a second message. A `subject:`
identity lasts 48 hours (`identity_ttl:` changes it).

For another workspace's key, build a client:
`Vatio::Server::Client.new(server_key:, workspace:, identity: config)`. The
`identity:` config signs `subject:` with that workspace's key.

##### Errors

Every refusal raises a `Vatio::Server::Error` with `code`, `message`, `status`,
`details` and `request_id`:

| Class | When |
|---|---|
| `OutsideWindow` | No message in 24 hours and no template sent; `last_inbound_at` |
| `OptedOut` | They asked not to be written to |
| `HumanAnswering` | A person has the conversation |
| `TemplateError` | Template missing, not approved, or wrong parameters |
| `InvalidRequest` | Any other `422` |
| `Unauthorized` | The key is wrong, revoked, or another workspace's |
| `Conflict` | Any other `409`, such as `idempotency_conflict` |
| `NotFound` | `message_status` for an id this key cannot see |
| `RateLimited` | More than 60 a minute on one key; `retryable?` |
| `Unavailable` | `5xx`, timeouts, refused connections; `retryable?` |

The full list of codes is in [Messages](https://vatio.ai/docs/api/messages).

##### Background jobs

Where ActiveJob is loaded, `Vatio::Server::SendMessageJob.perform_later` takes
the same keywords. It retries `RateLimited` and `Unavailable` with backoff, ten
attempts over about four hours, and logs and discards the rest. Subclass it
and `discard_on` an error to act on it. The job uses the global configuration.

##### Webhooks

```ruby
class VatioWebhooksController < ActionController::API
  include Vatio::Server::WebhookReceiver

  on_vatio_event "message.replied" do |event|
    message = Vatio::Server::Message.from(event["data"]["message"])
    Order.find_by(ref: message.external_ref)&.update!(customer_replied_at: message.replied_at)
  end
end
```

The receiver checks `Vatio-Signature` against the raw body, refuses a delivery
more than five minutes old, and answers `400` to anything that does not
verify. Delivery is at least once, so dedupe on `event["id"]`. Outside Rails,
`Vatio::Server::Webhook.verify!(payload:, signature:, secret:)`.

##### Tests

```ruby
require "vatio/server/testing"

setup    { Vatio::Server::Testing.fake! }
teardown { Vatio::Server::Testing.real! }
```

`fake!` swaps the network for an in-memory API that answers as the real one
does, through the same error mapping. `Testing.messages` is what was sent,
`fail_next!(:outside_window)` forces a refusal, and
`webhook("message.replied", ...)` builds a signed request for your receiver.
With RSpec, `have_sent_message(to:, brief:)`.

#### What it does not do

It does not rotate keys or cache tokens: every request verifies again, so an
expired token is caught. `vatio/server` does not retry on its own; retries
belong to `SendMessageJob`, or to whoever knows whether the message still
makes sense.

Bugs and questions go to
[github.com/vatio-ai/ruby/issues](https://github.com/vatio-ai/ruby/issues).

## API Reference

### API Reference

`https://vatio.ai/docs/api/`

Base URL: `https://vatio.ai`. All request bodies below are JSON; send
`Content-Type: application/json` on requests with a body. Replace `:slug`,
`:id`, and other path placeholders with the actual value.

Your customers do not use this API. A browser talks to Vatio through the
[SDK](https://vatio.ai/docs/sdk/) with a publishable token, over a separate surface that exposes one
conversation and nothing else about the workspace.

#### Getting a token

Every request below is authorized by a **developer token**, which looks like
`vat_…`. There is no token page in the inbox to copy one from: a developer
token is minted by device authorization, where a human approves this machine in
a browser once.

Start the flow, and read `device_code`, `user_code`, `verification_uri_complete`
and `interval` off the response:

```http
POST /api/developer/v1/device_authorizations
Content-Type: application/json
```

Send the person to `verification_uri_complete`, then poll at `interval` seconds
until it answers with the token instead of `authorization_pending`:

```http
POST /api/developer/v1/device_authorizations/token
Content-Type: application/json

{ "device_code": "..." }
```

Neither path is workspace-scoped and neither takes a token — they are how you
obtain one. [Device authorization](https://vatio.ai/docs/api/device-authorization) has the full
flow, including how to list and create workspaces with the result.

If you have a terminal in front of you, `vatio login` runs exactly these two
calls and writes the token to `~/.vatio/config.json`, where you can read it.

#### Credentials

Send the token as `Authorization: Bearer TOKEN`. Every path below is relative
to `/api/developer/v1/:slug`, where `:slug` is the workspace.

One token reaches every workspace its user owns. It can deploy, read secrets,
start conversations and mint publishable tokens, so it belongs on a server or
in a CI secret — never in a page, a mobile app, or a repository.

Errors carry `error_key` and `error_message`, or `error` and
`error_description` on authentication failures. See [Errors](https://vatio.ai/docs/api/errors).

#### Endpoints

- [Deployments](https://vatio.ai/docs/api/deployments) — Check, deploy, publish, roll back, and read deployment history.
- [Secrets](https://vatio.ai/docs/api/secrets) — Write-only workspace secrets your tools read as $env.KEY.
- [Knowledge bases](https://vatio.ai/docs/api/knowledge) — Bases and the entries you write into them.
- [Business](https://vatio.ai/docs/api/business) — The business name, description and opening hours.
- [Publishable tokens](https://vatio.ai/docs/api/tokens) — Mint and revoke the vatpub_ tokens a page carries.
- [Channels](https://vatio.ai/docs/api/channels) — Where WhatsApp and Instagram are set up for a workspace.
- [Chats](https://vatio.ai/docs/api/chats) — Your own developer conversations, with the full debug view.
- [Contacts](https://vatio.ai/docs/api/contacts) — Export everything about one person, or delete it for good.
- [Messages](https://vatio.ai/docs/api/messages) — Have the agent write to a WhatsApp contact first, from your backend, with a server key.

### Errors

`https://vatio.ai/docs/api/errors`

Authentication and access errors generally return:

```json
{
  "error": "workspace_forbidden",
  "error_description": "Token does not have access to this workspace",
  "request_id": "REQUEST_ID"
}
```

HTTP 401 means invalid credentials; 403 means missing permission or a disallowed
origin; 404 means a missing resource. Validation commonly uses 422.
A 403 with `two_factor_required` means the workspace's owner requires
[two-step verification](https://vatio.ai/docs/team#two-step-verification-and-sessions) and the
account does not have it yet.
Deploy and chat errors may use `error_key` and `error_message`.
A missing record, a record that fails validation, and the knowledge endpoints
use `{ "error": { "code": "...", "message": "..." } }`.
Retain `request_id` when the response includes one.

When the platform knows the next step, the error also carries `hint`: one
sentence saying what to run or change, such as
`"Run \`vatio login\`, then retry the same command."`. The CLI prints it as
`Next:`. Treat it as the recovery step rather than retrying the same request.

### Device authorization

`https://vatio.ai/docs/api/device-authorization`

`vatio login` is the normal route, and these paths are how it works. They are
not workspace-scoped and take no developer token — they are how you obtain one.

A custom CLI starts device authorization with
`POST /api/developer/v1/device_authorizations`. The response supplies `device_code`,
`user_code`, verification URLs, `interval`, and `expires_in`.

Open `verification_uri_complete` in the user's browser, or print it where
there is none. On that page they sign in with their email (we mail a link that
signs them in on any device, creating the account if it did not exist, and
brings them back) or with Google or GitHub, then approve the terminal — a
click no email scanner makes for them. Print the `user_code` in your
terminal: the page shows the same one, and comparing them is what replaces
typing it.

`email` is optional and legacy: older CLIs sent it, and then we mail the user
the link directly and the response carries `email_sent: true`. `vatio login`
and `vatio push` no longer send it.

`workspace` is optional: the slug of the workspace you are about to create or
push to. `vatio push` sends it, so that once the user approves, the page waits
for that workspace and then sends them to the inbox: to its **Start** page,
which waits for the push, or to its **Test** page once something is deployed.
A slug that is not valid is ignored.

Meanwhile, poll
`POST /api/developer/v1/device_authorizations/token` with `{ "device_code": "..." }` at
the supplied interval. `GET /api/developer/v1/workspaces` lists workspaces and
`POST /api/developer/v1/workspaces` creates one with `slug` and optional `name`, using the
resulting developer token.

### Deployments API

`https://vatio.ai/docs/api/deployments`

| Method | Path | Purpose |
|---|---|---|
| POST | `/check` | Validate a file bundle and return a manifest and diff |
| PATCH | `/push` | Deploy the validated manifest to preview |
| POST | `/publish` | Promote preview to live with its knowledge changes, once its tests allow it |
| POST | `/rollback` | Restore previous live |
| GET | `/status` | Deployment pointers |
| GET | `/manifest?environment=preview` | A deployed manifest |
| GET | `/env` | Both environments, with the branch preview was pushed from and its unpublished knowledge changes |
| GET | `/env/:name` | `preview` or `live`: token, share URL, knowledge changes and conflicts, its own secret names |

First call `POST /check` with the workspace files:

```json
{
  "environment": "preview",
  "files": [
    {
      "path": "vatio.yml",
      "encoding": "utf-8",
      "content": "workspace: acme\nagent:\n  instructions: Help visitors learn about Acme.\n"
    }
  ]
}
```

Each file has a relative `path`, `content`, and `encoding` (`utf-8` or `base64`).
The response contains `ok`, `manifest`, `errors`, `warnings`, and `diff`.
Diagnostics contain `message` and `path`. Validation failure is HTTP 200 with
`ok: false`; an unreadable or oversized bundle is 422.

When `ok` is true, send the returned `manifest` unchanged to
`PATCH /push` with optional `git_sha`, `git_branch` and `force`. Do not
construct the normalized manifest yourself. The response includes
`deployment_id`, `environment`, `share_url`, and `preview_url`; `share_url` is
the inbox's **Test** page for preview, open to the workspace's owner and
developers.
Once the workspace is published, a push from a different `git_branch` than
preview's unpublished version came from answers 422 with
`error_key: "preview_has_unpublished_push"`, unless `"force": true`
([Working with others](https://vatio.ai/docs/deployments#working-with-others)).

Publish promotes preview's current version. Its knowledge changes go live with
it, listed in `knowledge`; when live changed one of those entries meanwhile,
publish answers 409 with `error_key: "knowledge_conflict"` and nothing moves.
`notices` carries anything to show the developer, such as a deprecation, or
that preview was already live's version: then nothing moves but its knowledge
changes, and the answer describes live as it already was.
Once the workspace is published, publish answers 422 when preview's tests say
no ([Publishing](https://vatio.ai/docs/deployments#publishing)):

| `error_key` | When |
|---|---|
| `evals_missing` | Preview's version has not been tested: run `vatio eval`, then publish |
| `evals_running` | Its tests are still running |
| `evals_regressed` | A flag that passes on live fails on it. The body also has `regressions: [{ "id": "F-7K2QX", "title": "…" }]` |

`{ "force": true }` publishes anyway. Rollback needs no body. Neither
operation uploads your local files.

### Secrets API

`https://vatio.ai/docs/api/secrets`

| Method | Path | Purpose |
|---|---|---|
| GET | `/secrets` | Secret names |
| PATCH | `/secrets/:key` | Set a secret with `{ "value": "..." }` |
| DELETE | `/secrets/:key` | Remove a secret |

Values are write-only: reading lists keys, never contents.

Each call takes an optional `environment` (a query parameter, or a body field
on PATCH): `preview` or `live`. Without it, the value everywhere; with it, the
value only that environment uses instead. Listing without it returns every
secret, preview's own included; with it, what that environment can read: the
value everywhere, the test value and its own. Each comes with its
`environment` (`null` for the value everywhere) and `tests` (`true` for a test
value).

Keys use uppercase letters, digits and underscores, starting with a letter.
Keys starting with `VATIO_`, and `DISPATCHER`, are reserved for Vatio.

### Knowledge bases API

`https://vatio.ai/docs/api/knowledge`

| Method | Path | Body or result |
|---|---|---|
| GET / POST | `/knowledge_bases` | List bases / create with `{ "name": "docs" }` |
| GET / DELETE | `/knowledge_bases/:name` | Read a base / delete when unreferenced |
| PATCH | `/knowledge_bases/:name/entries/:entry` | `{ "content": "# Refunds\n..." }` — creates the entry or replaces it |
| GET | `/knowledge_bases/:name/entries/:entry` | The entry, with its stored Markdown; `?version=N` for one from its history |
| DELETE | `/knowledge_bases/:name/entries/:entry` | Delete an entry |
| GET | `/knowledge_bases/:name/changes` | What preview changed that live does not have, with `conflict` per entry |
| POST | `/knowledge_bases/:name/publish` | Take preview's changes to this base live |
| GET | `/knowledge_bases/:name/history` | Every version that reached live, newest first |

Entry reads and writes, `changes` and `publish` take `environment` (a query
parameter, or a body field on writes): `preview`, the default, or `live`. A
write in preview is answered from there only and goes live when preview is
published; `"environment": "live"` writes live directly. See
[Preview and live](https://vatio.ai/docs/knowledge#preview-and-live).

`publish` answers 409 with `error_key: "knowledge_conflict"` when live changed
one of those entries after preview did, and 422 `nothing_to_publish`
when there is nothing to take live. The old `snapshots`,
`snapshots/:number/rollback`, `sites` and `refresh` paths answer 410.

### Business API

`https://vatio.ai/docs/api/business`

| Method | Path | Body or result |
|---|---|---|
| GET | `/business` | The business: `name`, `summary`, `about`, `accent_color`, `default_accent_color`, `logo_url`, `hours`, `open_now`, `next_opening`, `handoff`, `limits` |
| PATCH | `/business` | `{ "name": "...", "summary": "...", "about": "...", "accent_color": "#8A4B2A", "logo": { "data": "<base64>", "content_type": "image/png", "filename": "logo.png" }, "hours": { "timezone": "America/Santiago", "days": { "mon": ["09:00-18:00"] } } }`; returns the business |

The same row the [inbox's Business page](https://vatio.ai/docs/channels/inbox#business) edits: one
value for every environment, live on save. Only the keys sent change.
`summary` is the description the agent's prompt carries (up to 2,000
characters); `name` up to 100.

`hours` replaces the schedule as a whole: an IANA `timezone` and, per day
(`mon` … `sun`), a list of 24h `HH:MM-HH:MM` ranges that end after they start.
A day left out is closed. `"hours": null` clears it — someone is always
available — and `GET` answers `hours: null` the same way. An unknown zone or a
bad range answers 422.

The brand: `about` is what the widget shows before anyone writes (up to 2,000
characters), `accent_color` is `#RRGGBB` or `null` for Vatio's default, and
`logo` is a PNG, JPEG, WebP or GIF of up to 2 MB, base64 in the body; `"logo":
null` removes it. `logo_url` is the 200px avatar the widget and the chat page
show. A request with a bad logo saves nothing.

`handoff` is read-only, from the live agent: it is declared in `vatio.yml`.

### Publishable tokens API

`https://vatio.ai/docs/api/tokens`

| Method | Path | Body or result |
|---|---|---|
| GET | `/publishable_tokens` | List active developer-created tokens: prefix, environment, label, `allowed_origins` |
| POST | `/publishable_tokens` | `{ "environment": "live", "label": "website", "allowed_origins": ["https://acme.com"] }`; returns the full token once |
| PATCH | `/publishable_tokens/:prefix` | `{ "allowed_origins": [...] }`; replaces the token's allowed origins |
| DELETE | `/publishable_tokens/:prefix` | Revoke a token |
| GET | `/widget` | Widget configuration, allowed origins, agent availability, tokens, and links to the inbox |

A publishable token is for `live` or `preview`; omission defaults to `live`. A token works only from its own
`allowed_origins`, plus Vatio's own pages and the
[app builders](https://vatio.ai/docs/channels/widget); an origin that is not
`scheme://host[:port]`, `ios-app://BUNDLE_ID` or `android-app://PACKAGE_NAME`
answers 422. The managed token
in the inbox's install snippet is not listed here; its origins are edited in
the tokens section of **Channels → Web widget** in the inbox.

`GET /widget` is read-only. Its `allowed_origins` is every origin some active
token accepts, each token in `publishable_tokens` carries its own list, and
`brand_url` and `tokens_url` link to **Channels → Web widget** in the inbox,
where the widget's look and the tokens' origins are edited.

### Channels API

`https://vatio.ai/docs/api/channels`

WhatsApp and Instagram are set up in the inbox, not over HTTP: connecting
your own number or account needs Meta's consent screen, and a test phone or
account is proven by something only its owner can do.

- **Test phones and accounts** are verified on the inbox's **Test** page, in
  its **WhatsApp** and **Instagram** tabs. A phone scans a QR code there and
  sends the code it carries to Vatio's preview number on WhatsApp; the message
  verifies it. An Instagram account is shown a code and DMs it to Vatio's
  sandbox account; the DM verifies it.
- **Your own number or account** is connected in **Channels**, once you
  publish.

`GET /status` says where to go for this workspace:

| Field | Value |
|---|---|
| `published` | `true` once live has a deployment |
| `whatsapp_url` | Test → WhatsApp before publishing, Channels → WhatsApp after |
| `instagram_url` | Test → Instagram before publishing, Channels → Instagram after |
| `integrations_url` | Test before publishing, Channels after |

#### Who the agent answers on WhatsApp

| Method | Path | Body / answer |
|---|---|---|
| GET | `/whatsapp` | `{ "display_phone_number", "live", "agent_numbers": [...] }` |
| PATCH | `/whatsapp` | `{ "agent_numbers": ["+56912345678"] }` replaces the list; `[]` is everyone |

On the workspace's own number: with a list, the agent answers only those
numbers, plus the numbers of the workspace's
[routines](https://vatio.ai/docs/integrations/claude-routines), and everyone else's messages reach
the inbox unanswered. A number not in international format is `422`, and a
workspace with no number of its own `409 whatsapp_not_connected`. **Live**
stays a switch in the inbox, under **Channels → WhatsApp**.

The endpoints that used to do this — `/whatsapp_account`,
`/instagram_account`, `/test_phone_numbers`, `/test_instagram_accounts` —
answer `410 Gone` with the code `moved_to_console` (a name kept for older
CLIs) and the link to the page in the inbox.

### Chats API

`https://vatio.ai/docs/api/chats`

A chat here is you, the developer holding the token, on channel `cli`. It is
not a way to impersonate a customer: `channel`, `from`, `as`, `session_id`,
`reply_style`, `email` and `phone_number` are all refused, and `environment` is
the only field create and reset accept.

| Method | Path | Body / query |
|---|---|---|
| POST | `/chats` | `{ "environment": "preview" }` → `chat_id` |
| GET | `/chats/:id` | Any chat in the workspace, on any channel: its `channel`, `environment`, `deployment_id` and identity state |
| POST | `/chats/:id/reset` | `{ "environment": "preview" }` → a new `chat_id` |
| POST | `/chats/:id/messages` | `{ "content": "Hello" }` → 202 with `user_message_id` |
| GET | `/chats/:id/messages` | `after` and `limit` |

**`environment` is required on create and reset**, and it is the one field in
this API with no default. Everywhere else an omitted environment falls back to
something harmless; here the fallback would be `live`, and a request missing one
word would be a real conversation with your published agent — billed, visible in
the inbox, and running your tools against live secrets. Send `preview` to
talk to a preview deployment, or `live` when you mean it. A name that is not an
environment is 422, not a guess.

Replies are asynchronous: post a message, then poll `GET /chats/:id/messages`
with `after` set to the last id you have seen.

Both read endpoints always return the developer view, because the token already
is the developer view — deleted messages, tool calls with their results,
attachments, channel delivery receipts, and an `identity` block saying whether
the workspace recognized the visitor and why not. There is no view parameter to
choose; print as much of the payload as your caller needs.

This API only sees the conversations it started. To read what real customers
said on the widget, WhatsApp or Instagram, use [the inbox](https://vatio.ai/docs/channels/inbox).

### Contacts API

`https://vatio.ai/docs/api/contacts`

| Method | Path | Purpose |
|---|---|---|
| GET | `/contacts/:id/export` | Everything kept about one contact, as JSON |
| DELETE | `/contacts/:id` | Delete the contact and everything about them |

For a person's access, portability and erasure requests. `:id` is the
contact's id, as the inbox and webhooks show it. Both calls are recorded in
the workspace's **Activity**. See [Retention and deletion](https://vatio.ai/docs/data) for what
each one covers.

#### Export

```http
GET /api/developer/v1/:slug/contacts/:id/export
Authorization: Bearer TOKEN
```

```json
{
  "exported_at": "2026-10-09T21:14:03Z",
  "workspace": "acme",
  "contact": {
    "id": 4211,
    "environment": "live",
    "name": "Ana Pérez",
    "email": "ana@example.com",
    "phone_number": "+56911112222",
    "opted_out_at": null,
    "created_at": "2026-09-30T12:01:44Z",
    "channels": [{ "channel": "whatsapp", "handle": "+56 9 1111 2222" }],
    "memories": [{ "kind": "note", "body": "Prefers mornings", "created_at": "2026-10-01T09:12:00Z" }]
  },
  "conversations": [
    {
      "id": 9120,
      "agent": "support",
      "source": "whatsapp",
      "environment": "live",
      "started_at": "2026-09-30T12:01:44Z",
      "messages": [
        { "role": "user", "from_a_person": false, "content": "Hi", "attachments": [], "sent_at": "2026-09-30T12:01:44Z" }
      ]
    }
  ]
}
```

`from_a_person` is `true` on a reply somebody wrote from the inbox or the
channel's own app instead of the agent.

#### Delete

```http
DELETE /api/developer/v1/:slug/contacts/:id
Authorization: Bearer TOKEN
```

```json
{ "erased": true, "contact_id": 4211, "chats": 3, "messages": 58 }
```

There is no undo. A contact that does not exist, or belongs to another
workspace, answers `404`.

### Messages API

`https://vatio.ai/docs/api/messages`

Your backend tells Vatio who to write to and what to say, and the
conversation opens on WhatsApp: an order that shipped, an appointment
tomorrow, a quote that is ready. When the person answers, the agent already
knows what was said and why.

```http
POST /api/server/v1/:slug/messages
Authorization: Bearer vsk_…
Content-Type: application/json
Idempotency-Key: order-1042-shipped

{
  "to": "+56912345678",
  "brief": "Order #1042 shipped today with Starken, tracking 99812. Offer to send the tracking link.",
  "template": { "name": "order_shipped", "language": "es", "params": ["Ana", "#1042"] },
  "external_ref": "order-1042"
}
```

In Ruby, the [`vatio-sdk`](https://vatio.ai/docs/sdk/ruby#message-people-first) gem's
`require "vatio/server"` wraps all of this: typed errors, an ActiveJob that
retries only what is safe to retry, a webhook receiver, and a test fake.

This is the one endpoint family outside `/api/developer/v1`. Your server calls
it, not the CLI, and it takes a **server key**, not a developer token.

#### Server keys

A server key (`vsk_…`) belongs to the workspace and one environment. It does
not expire when someone signs the CLI in again, and a preview key cannot
message a live customer. Create one with [`vatio keys create`](https://vatio.ai/docs/cli/keys) or
over the developer API:

| Method | Path | Body or result |
|---|---|---|
| GET | `/api/developer/v1/:slug/server_keys` | Active keys: prefix, environment, label, `last_used_at` |
| POST | `/api/developer/v1/:slug/server_keys` | `{ "environment": "live", "label": "backend" }`; returns the full `key` once |
| DELETE | `/api/developer/v1/:slug/server_keys/:prefix` | Revoke a key |

Keep it on your server. It can message your customers.

#### What goes out

WhatsApp delivers free-form text only within 24 hours of the person's last
message. After that, it only delivers a template that Meta approved. Vatio
checks the window, and your request decides what is sent:

| The contact wrote… | You sent `text` | You sent only `brief` |
|---|---|---|
| in the last 24 hours | `text`, exactly as written (`mode: "text"`) | The agent writes it from the brief (`mode: "agent"`) |
| more than 24 hours ago, or never | Your `template`, as is (`mode: "template"`) | Your `template`, as is (`mode: "template"`) |

With the window closed and no `template`, the request is refused with
`422 outside_window`. To always reach someone, send a `template` as well;
Vatio only uses it when it has to.

- **`text`** is for words that must not change, such as an amount or a legal
  notice. It goes out as one message, with no model in between.
- **`brief`** is your words to the agent, not to the customer. Write it the
  way you would brief a colleague, with the facts it should use; the agent
  uses no facts beyond it and its tools. Say who the news is from and the
  agent passes it on as theirs: "Tell Ana that Claude finished: …" goes out as
  "Claude tells me that…".
- **`template`**: `params` fill the body's `{{1}}`, `{{2}}`, … in order, and
  the count must match. [`GET /templates`](#lists-and-templates) lists each template's
  count.

You can send both `text` and `brief`. The text goes out, and the brief stays
with the conversation as context. In every mode the agent is shown the brief
on each later turn, so "yes, send it" gets an answer that knows what "it" is.

#### Body

| Field | | |
|---|---|---|
| `to` | required | The contact's number in international format |
| `text` | `text` or `brief` | Your own message, sent as is inside the window. Up to 4,096 characters |
| `brief` | `text` or `brief` | What the agent should write about, and context for later turns. Up to 2,000 characters |
| `template` | optional | `{ "name", "language", "params": [] }`, used only outside the window |
| `identity` | optional | A JWT your workspace signed for this person; see [Identity](#identity) |
| `external_ref` | optional | Your own id. Filter by it with `GET /messages?external_ref=` |

#### Responses

A message is recorded by the request and sent by Vatio right after. The
request never waits on Meta or on the model, so it answers in milliseconds:

- **`202`**: accepted, `status: "queued"`. Follow it with
  [webhooks](#webhooks) or `GET /messages/:id`.
- **`200`** with `Idempotent-Replayed: true`: an `Idempotency-Key` that was
  already used. You get the original message as it stands now; nothing is
  sent again.

```json
{
  "id": 81,
  "status": "sent",
  "mode": "template",
  "channel": "whatsapp",
  "environment": "live",
  "to": "+56912345678",
  "chat_id": 5521,
  "external_ref": "order-1042",
  "idempotency_key": "order-1042-shipped",
  "brief": "Order #1042 shipped today…",
  "text": null,
  "template": { "name": "order_shipped", "language": "es", "params": ["Ana", "#1042"] },
  "content": "Hi Ana, your order #1042 is on its way…",
  "chat_message_id": 90412,
  "delivery": { "status": "read", "sent_at": "…", "delivered_at": "…", "read_at": "…", "error_code": null, "error_message": null },
  "error": null,
  "sent_at": "…",
  "replied_at": "…",
  "created_at": "…"
}
```

- `status` is the message's own state: `queued`, `sent` or `failed`.
- `content` is what reached the phone: your text, the agent's words or the
  rendered template. `chat_message_id` is that message in the conversation.
- `delivery` is Meta's word on the first message: `sent`, `delivered`, `read`
  or `failed`, with Meta's error code.
- A `failed` message carries `error.code`:
  - `human_takeover`, `handoff_pending`: someone took the chat first.
  - `outside_window`: the window closed while it waited.
  - `chat_busy`: the chat was busy for ten minutes.
  - `no_reply`, `generation_failed`: the agent wrote nothing.
  - `whatsapp_error`: Meta refused the send; Meta's message is in
    `error.message`.

A failed message stays silent: Vatio never sends the person an apology for a
message they did not ask for. Nothing is retried against Meta, either. A send
that timed out may have arrived, and a second one would be a second message
on the person's phone.

#### Idempotency

Send `Idempotency-Key` (or `idempotency_key` in the body) and retry as much as
you like: the same key, in the same environment, always returns the same
message. A key sent with a different body is refused with
`409 idempotency_conflict`. Derive it from your own event, such as
`order-1042-shipped`, so a retry from a job queue cannot message anyone twice.
An `identity` signed again for the same person, with a new `exp`, is still
the same request.

#### Identity

On WhatsApp, the person is anonymous unless you say otherwise. Send
`identity`, a JWT your workspace signed with the same key as the widget's
(`sub`, `aud` = the workspace slug, `exp`), and the conversation is signed in:
when the person answers, [private tools](https://vatio.ai/docs/authentication/tools) run for that
`sub`. Vatio checks the signature; it never vouches for anyone. Give it an
`exp` that covers how long they may take to answer, such as 48 hours.

A conversation already signed in as someone else is not handed over:
`409 identity_conflict`.

#### Webhooks

Vatio POSTs what happened to your server, so you never poll:

| Event | When |
|---|---|
| `message.sent` | It went out to WhatsApp |
| `message.failed` | It could not go out; `data.message.error` says why |
| `message.delivered`, `message.read` | Meta says it reached the phone, or was read |
| `message.delivery_failed` | Meta refused it after sending, most often because the window closed |
| `message.replied` | The person answered, once per message; `data.reply` has the text |

```json
{
  "id": "evt_3141",
  "type": "message.replied",
  "created_at": "…",
  "workspace": "acme",
  "environment": "live",
  "data": {
    "message": { "id": 81, "status": "sent", "external_ref": "order-1042", "...": "…" },
    "reply": { "chat_message_id": 90433, "content": "Great, send me the link", "at": "…" }
  }
}
```

Every delivery carries `Vatio-Event`, `Vatio-Delivery` (the event `id`) and
`Vatio-Signature: t=1696262400,v1=…`. To verify it, compute the hex
HMAC-SHA256 of `"#{t}.#{raw body}"` with the endpoint's secret, compare it in
constant time, and reject a `t` older than five minutes. Answer `2xx` within
ten seconds. Anything else, including a redirect, is retried with backoff for
about a day. After 50 failures in a row the endpoint is switched off until you
enable it again. Deliveries are at least once, so dedupe on `id`.

| Method | Path (developer API) | |
|---|---|---|
| GET | `/webhook_endpoints` | Endpoints, with their last success and failures |
| POST | `/webhook_endpoints` | `{ "environment": "live", "url": "https://…", "events": [] }` (empty = all); returns `secret` once |
| PATCH | `/webhook_endpoints/:id` | `url`, `events`, `label`, or `{ "enabled": true }` |
| DELETE | `/webhook_endpoints/:id` | Remove it |
| POST | `/webhook_endpoints/:id/test` | Queue a `ping` |
| GET | `/webhook_endpoints/:id/deliveries` | The last 50 deliveries and how each went |

The URL must be `https` on a public host. Each endpoint belongs to one
environment and hears only that environment's events.

#### Lists and templates

| Method | Path | |
|---|---|---|
| GET | `/messages/:id` | One message |
| GET | `/messages?external_ref=&status=&limit=&before=` | Newest first, up to 50; page with `before` = the `next_before` of the last page |
| GET | `/templates` | The templates this key's number can send: `name`, `language`, `status`, `body`, `parameter_count`, and an `example` object to send as `template` |

Create templates with [`vatio templates create`](https://vatio.ai/docs/cli/webhooks#templates). Until
you connect your own number it puts them on Vatio's test number, where a
preview key can send them, and moves them to your number when you connect
it. The inbox's **Outreach → Templates** shows the same list, with each
one's body, status and the reason Meta gave when it refused one. Once you
connect your number you can also create them there or in Meta. Meta approves
each one on each number.
Vatio reads its own copy of them. If
you name one on your own number that it does not know yet, the request is
refused with `template_not_found`, and Vatio fetches the list from Meta in the
background: retry in a minute.

#### Read a conversation

```http
GET /api/server/v1/:slug/chats/:id/messages?before=&limit=
Authorization: Bearer vsk_…
```

One conversation of the key's environment, as both sides saw it: what a
[Claude Code routine](https://vatio.ai/docs/integrations/claude-routines) reads first, from the
`messages_url` it was started with. The newest messages come last, up to 100
(`limit`, at most 200); pass `before` = `oldest_id` to get the page before, and
stop when `has_more` is false.

```json
{
  "chat": { "id": 349, "channel": "whatsapp", "environment": "live",
            "contact": { "name": "Ivan", "phone_number": "+56912345678" } },
  "messages": [
    { "id": 9875, "role": "user", "content": "¿Pueden cambiar el banner?",
      "forwarded": "forwarded", "occurred_at": "2026-10-11T16:02:11Z", "attachments": [] },
    { "id": 9876, "role": "user", "content": "Implementa lo que me pide Sofía",
      "forwarded": null, "occurred_at": "2026-10-11T16:02:40Z", "attachments": [] }
  ],
  "oldest_id": 9801,
  "has_more": true
}
```

`role` is `user` for the person on WhatsApp and `assistant` for everything
sent to them: the agent, a person from the inbox (`human_author_name`) or your
backend. `forwarded` is `forwarded` or `frequently_forwarded` when the message
came from another chat. A voice note's `content` is its transcript; images and
files are in `attachments`, as signed URLs, with the text Vatio read in a PDF
or a video as `description`. Tool calls and discarded drafts are not part of
it. Every read is recorded in the workspace's Activity, and a key reads at
most 120 times a minute.

#### Refusals

Errors carry `{ "error": { "code", "message" }, "request_id" }`.

| Status | `code` | |
|---|---|---|
| 401 | `invalid_key` | Missing, wrong or revoked key |
| 403 | `workspace_forbidden` | The key belongs to another workspace |
| 404 | `not_found` | No such chat in this key's environment |
| 409 | `opted_out` | The contact asked not to be written to |
| 409 | `human_takeover`, `handoff_pending` | A person is handling the conversation |
| 409 | `whatsapp_not_connected`, `whatsapp_paused` | No number, or **Live** is off on it |
| 409 | `use_live_key` | A preview key, on a workspace with its own number connected |
| 409 | `idempotency_conflict`, `identity_conflict` | See [Idempotency](#idempotency) and [Identity](#identity) |
| 422 | `outside_window` | Window closed and no `template`; carries `last_inbound_at` |
| 422 | `template_not_found`, `template_not_approved`, `template_params`, `template_required` | The template cannot be sent as given |
| 422 | `invalid_identity`, `identity_not_configured` | The token does not verify, or the deployment has no `auth:` key |
| 422 | `not_a_test_phone` | A preview key, to a number that is not a verified test phone |
| 422 | `content_required`, `text_too_long`, `brief_too_long`, `invalid_recipient` | |
| 429 | `rate_limited` | More than 60 messages, or 120 conversation reads, a minute on one key |

#### Preview

A preview key writes from the shared preview number, and only to a test phone
verified for that workspace and environment on **Test → WhatsApp**. Outside
the 24-hour window it sends the templates you created with
[`vatio templates create`](https://vatio.ai/docs/cli/webhooks#templates), by the same name you send
them by from your own number.

The test number is for a workspace with no number of its own. Once you
connect yours, your templates move to it and a preview key is refused with
`409 use_live_key`: your backend sends from your number, with a live key.

##### Try it from your phone

1. Add your phone on **Test → WhatsApp**, as in
   [Test with the shared preview](https://vatio.ai/docs/channels/whatsapp#test-with-the-shared-preview).
2. Create a preview key: `vatio keys create --env preview --label try`.
3. Write anything from your phone to the preview number. That opens the
   24-hour window, so `brief` and `text` go out without a template.
4. Send a message:

   ```sh
   curl https://vatio.ai/api/server/v1/acme/messages \
     -H "Authorization: Bearer $VATIO_SERVER_KEY" \
     -H "Content-Type: application/json" \
     -H "Idempotency-Key: try-1" \
     -d '{"to": "+56912345678", "brief": "Order #1042 shipped today, tracking 99812. Offer to send the tracking link."}'
   ```

It answers `202` with the message's `id`, and the agent's message reaches your
phone seconds later. `GET /messages/:id` shows what went out, and the inbox's
**Outreach** page lists it. Answer it from your phone: the agent knows what it
wrote and why.
