#Vatio

Vatio runs production AI agents. You write a small manifest — one agent, plus any tools and knowledge it needs — and the platform provides the conversation runtime, CRM, authentication, channels (web and WhatsApp, with a shared WhatsApp preview you can use immediately), safeguards, and preview/live deployments.

Your agent does not run on your instructions alone. Vatio wraps every turn in a hardened operating layer — the accumulated result of running agents in front of real customers — covering prompt-injection resistance, refusal of unsafe or fabricated content, output formatting, and blocking links the agent invented rather than got from a tool. You get that behavior by default; you do not configure it, and it takes precedence over anything in your manifest.

A deployable agent is a single file. Start with the Quick Start; read Core Concepts before writing prompts.

#Quick Start

#Install

curl -fsSL https://vatio.ai/install.sh | bash

Installs into ~/.vatio-cli/ and links vatio into ~/.local/bin/. Requires only curl, tar, and a Ruby >= 2.6 already on your PATH — no git, gem, or brew needed. The installer always serves the CLI build that matches the platform you are installing against, so vatio and the API never drift apart.

Upgrade with vatio update.

#Authenticate

From anywhere:

vatio login

This opens a browser for device authorization and writes your token to ~/.vatio/config.json. Once per machine, not once per agent — your credentials belong to you, not to a checkout, so they never sit inside a repository.

#Create the workspace

A workspace is any directory holding a vatio.yml. Put it where the code it serves lives — beside your backend, in a monorepo, or in a repository of its own:

mkdir support-agent && cd support-agent
vatio init acme

That creates the remote workspace acme and writes a starter vatio.yml here, plus a .mcp.json that points a coding agent at this workspace — see coding agents. The slug argument is optional; without it the directory name is used. The workspace's display name starts as Acme, from the slug; set business.name when you want something else.

#vatio.yml

vatio.yml is the one required file in a workspace, and the file that makes a directory one. workspace: names the remote it deploys to; business: and widget: describe the workspace; agent: is the agent that answers every conversation.

agent: is required. Leaving it out is an error rather than a workspace that deploys and then answers nothing — forgetting it is far more common than meaning it. To mean it, say so:

workspace: my-auth
agent: false

A workspace with agent: false serves only the phone verification API — WhatsApp OTP, to authenticate people in your own product. It has no conversation, so it can carry no widget:, no inbox:, no tools/, no auth/, no knowledge:, no links:; declaring any of them alongside agent: false is an error, not a silently ignored block. business: still applies, since the workspace still has a name.

This is a property of the workspace, not of the API: a workspace with an agent can call the verification endpoints too. agent: false only says that the verification endpoints are all it does.

workspace: acme

workspace: is addressing, not behavior: it says which remote this directory talks to and is the one line that never travels inside a deployment. Every command reads it from the vatio.yml at or above your current directory — there is no --workspace flag and no environment override, because a slug read from anywhere else is a push landing in the wrong workspace.

business:
  name: Acme
  summary: Acme sells warehouse robotics.
widget:
  accent_color: "#3355FF"
agent:
  name: Asistente
  instructions: >-
    Answer visitor questions about Acme using known business information.
    If you do not know something, say so. Never invent prices or availability.
  personality: Warm, brief, and clear.
  tools: []

Only agent: is required, and only agent.instructions inside it — a manifest without it deploys a workspace nothing can talk to, so it is rejected. A workspace has exactly one agent, which is why the key is singular; agents: is refused rather than silently ignored.

#Deploy

vatio push

push validates the manifest, creates the remote workspace if it does not exist, and updates the preview deployment. Preview is live immediately for the CLI. The first push for a new workspace also prints a link to connect a free WhatsApp preview number, so you can try the agent from your own phone right after this step — see WhatsApp.

#Talk to it

vatio chat "Hi, what do you do?"

vatio chat always talks to preview and never sends a real WhatsApp or email. Keep sending turns to continue the same conversation:

vatio chat "Do you have monthly plans?"
vatio chat transcript --last 10    # what the visitor saw
vatio chat debug --last 30         # tool calls, routing, auth state
vatio chat reset                   # start a fresh conversation

#Publish

vatio publish

Preview is promoted to live. Public visitors reach the agent at https://vatio.ai/w/acme. vatio rollback restores the previous live version.

#The whole thing

vatio login                     # once per machine
mkdir support-agent && cd support-agent
vatio init acme                 # creates the remote and writes vatio.yml
# edit vatio.yml
vatio push
vatio chat "Hi"
vatio publish

#Core Concepts

#Workspace and manifest

A workspace is one client or product. Its behavior is a directory of plain files — the manifest — that you deploy with vatio push.

my-backend/                one repository
  app/ …                   your code
  support-agent/           a workspace — any directory with a vatio.yml
    vatio.yml              required — workspace, business, identity, widget, agent
    identity.pub           public key, if vatio.yml declares identity:
    inbox.pub              public key, if vatio.yml declares inbox:
    tools/*.js             LLM-callable tools
    tools/*.yml            declarative HTTP tools (no sandbox round-trip)
    lib/*.js               shared JS helpers (no spec)
    auth/*.js              authentication providers — one scheme each

There is no knowledge/ directory. A knowledge base is its own thing, filled with vatio kb and named from vatio.yml — see Knowledge bases.

A workspace root is the nearest directory at or above your current one that holds a vatio.yml. Nothing above it matters, so the layout is yours: one workspace per repository, several under an agents/ folder, or one beside each service in a monorepo. Keeping it next to the backend it calls is usually right — the tools point at that backend's endpoints, and the two change together.

Credentials are the one thing that is not per directory: they live in ~/.vatio/config.json, per developer, for every workspace on the machine.

Only vatio.yml is required. Create directories only when you have a file to put in them.

The manifest is the only way to configure a workspace. The Vatio app shows everything it describes — the agent, the tools, the widget, the business name and summary — read-only, because vatio push is what changes them; there is no field in the app that a later deploy would silently revert. Your workspace directory in version control is therefore the whole truth about how the agent behaves.

Knowledge bases are the exception, and deliberately so: they are not part of a deployment, so the app edits them directly. Everything there has a vatio kb command too.

The app still does the things a manifest cannot describe: reading conversations and contacts, setting secret values, connecting WhatsApp, filling and reindexing knowledge bases, publishing and rolling back, and deleting the workspace.

#Agents

An agent is a persona — instructions, a personality, and an explicit list of tools it may call. It only sees tools listed under tools:; the platform never infers them.

Field Required Meaning
key yes Must be main
name no Label in the Vatio app; defaults from the key
instructions yes What the agent does and how it decides
personality no How it sounds. Defaults to a warm, brief, clear voice
tools no Tool keys it may call. Omit for a conversation-only agent
business no Who the agent works for — see below

instructions is one section of the system prompt, not the prompt itself — the platform wraps it with the operating layer described above, the business summary, contact state, and your tool descriptions. Write behavior there and leave voice to personality. Anything in instructions that contradicts the platform's safety and formatting rules is overridden, so write what your agent should do rather than trying to restate how it should behave.

The optional business block is the workspace's own identity, as opposed to the agent's persona. Both keys are optional, and omitting one leaves the current value alone:

key: main
name: Acme Support
business:
  name: Acme
  summary: |
    Acme sells warehouse robotics to mid-size distributors in Chile and Peru.
instructions: |
  ...
Key Meaning
business.name The workspace's display name, up to 100 characters. Defaults to the slug titleized when vatio init creates the workspace
business.summary Up to 2000 characters of business context. The prompt puts it right after "You work for…", so it frames every reply

business.name is not the agent's name: the first is the company, the second is what the agent is called. business.summary is context the agent reads; about is copy the visitor reads. Neither substitutes for the other.

A workspace has exactly one agent, keyed main. It answers every conversation on every channel, from the first message to the last. There are no platform-owned agents, no handoffs, and no phases: whatever the visitor needs, main handles it with the tools you assign.

That means everything the conversation should do is expressed in two places — the agent's instructions and its tools. If you want the agent to ask for a name before helping, say so there and give it identify_contact; there is no separate agent or gate to configure.

#Tools and the result contract

A tool is a JavaScript function or a declarative HTTP call the agent can call — see JavaScript tools and Declarative HTTP tools. Whichever kind, every tool returns:

Field Type Required
result "ok" or "error" yes
message non-empty string yes

result describes the call, not whether the news is good.

{ result: "ok",    message: "3 spots left in Yoga.",   data: { spots: 3 } }
{ result: "ok",    message: "Pilates is full.",        data: { spots: 0 } }
{ result: "error", message: "We could not check availability.", error_key: "backend_unavailable" }

This distinction matters. If you mark an ordinary negative answer as error, you misrepresent what happened — the agent's instructions tell it to handle a real failure differently than a negative result, and marking a working query as error needlessly engages the platform's failure handling for a case that was not a failure. When in doubt ask: did the tool answer the question it was asked? If yes, it is ok.

The runtime rejects anything that is not an object, is missing a valid result, or has an empty message, and substitutes a generic failure — your tool will look broken. Extra fields (data, error_key, domain ids) are allowed and passed to the model as facts.

#Identity

Vatio distinguishes what the visitor typed from what the channel proves:

Concept Meaning
Contact The CRM record for a visitor, per workspace and environment — see CRM
Channel identity How they arrived (web, whatsapp, email, cli) and the identifier that channel carries
Principal Who they are in your backend, once your provider recognizes them

Authentication is the channel plus your provider's resolve. A visitor arriving on WhatsApp carries a phone number the channel verified; you look that number up in your backend and, if you recognize it, the visitor is authenticated for the rest of the conversation. There are no session records, no expiry, and no login prompt — the conversation is the session.

Three rules follow, and they matter:

A tool can never mark someone authenticated. Only your provider can, and only through resolve.

#Preview and live

A deployment is immutable — a manifest snapshot plus its git_sha — and an environment is a name pointing at one. live is such a name, preview is another, and publishing moves a pointer rather than building anything.

preview live
Updated by vatio push vatio publish
Used by vatio chat, preview in the app Public web chat, WhatsApp, real visitors
Contacts Separate preview contacts Separate live contacts

Contacts and their identities are scoped per (workspace, environment) — preview traffic can never touch live customer data. The CLI never talks to live.

Integrations and secrets are the exception: they are shared by both environments. See Secrets.

#Named previews

preview is the default preview, not the only one. --as NAME lands a push on its own preview, which is how one pull request gets a deployment you can open without disturbing another:

vatio push --as pr-42
vatio publish --as pr-42    # when it merges

A named preview gets its own agent, tools, auth providers, chats and contacts, materialized separately — so two open pull requests never see each other's conversations. What it does not get is its own copy of your knowledge bases: those belong to the workspace and every environment reads the same ones, which is what keeps a new preview from re-crawling and re-embedding your whole corpus.

live is never a push target. A push that could land there directly would skip the promote that makes publishing a decision, so it is refused.

Each preview has a link that opens without a login, printed by vatio push:

$ vatio push --as pr-42
Pushed deployment #318 to pr-42 (git_sha="9c1f…")
Open it: https://vatio.ai/w/acme/p/6f1c0b0a7d2e4f58a1b9c3d5e7f10234

The URL is the credential, so treat it like one — anybody who has it can talk to that preview. It stops working the moment the preview is torn down, and it is unrelated to the authenticated preview screen in the app, which is still there for you.

#Safeguards

Two deterministic checks run on every draft reply. A violation discards the draft and triggers a correction pass, invisible to the visitor:

Safeguard Catches
No empty content A reply with no visible text and no tool call
No unauthorized links A URL that is neither declared in links: nor returned by a tool in this chat

Whether a reply's wording matches what a tool reported is not a deterministic check — it is left to the agent's instructions. The platform's own prompt already tells the model not to claim a tool succeeded when it returned result: "error", and to use the tool's message to describe what actually happened. Write message as the concrete, factual outcome ("We could not load the schedule." rather than "Operation failed.") so the model has something worth reusing — there is no wording or vocabulary requirement on your side to satisfy.

If the platform ever cannot produce any reply at all (both checks above keep failing, or the turn errors out), the visitor sees a small built-in fallback message instead of silence — see Language for how its language is chosen.

A URL written into instructions: never reaches a visitor. The safeguard above allows only URLs the agent was actually given, and instructions: is text the model is free to rephrase — so a link in there is a link the model is free to get wrong. The draft is discarded and the correction pass strips it.

links: is where a URL is given. It sits at the root of vatio.yml:

links:
  login: https://saludtech.cl/login
  agendar:
    url: https://saludtech.cl/{especialidad}
    when: The visitor wants to book an appointment
    values:
      especialidad: [nutricion, kinesiologia, psicologia]

A link with nothing to fill in is just its URL. A link with a {placeholder} lists every value that placeholder can take, and a deploy expands it into one concrete URL per value — three, above. Those URLs are what the agent is shown and what the safeguard allows; it copies one, it never assembles one.

The values: list is the point, and leaving it out is an error. An open template would have the agent fill in the slug itself, and nothing downstream could tell a real specialty from a plausible one: /dermatologia matches the shape exactly as well as /nutricion does, and the 404 lands on the visitor. Listing the values checks membership rather than shape. It also tells the agent which specialties exist, which a template never could — so you do not need to repeat them in instructions:.

Field Required Meaning
url yes An absolute http/https URL. A bare string in place of the mapping is this field
when no When to reach for it. Same role as a tool's when_to_use
values only if the URL has {placeholders} Each placeholder, and every value it may take

Every value is one whole path segment: letters, digits, ., _, ~, -. That restriction is the escaping — there is no percent-encoding to get right, and no way for a value to bolt an extra segment, a query string, or a fragment onto a URL you thought you had written in full.

A link with several placeholders expands to every combination, and the whole block is capped at 100 URLs. A catalogue bigger than that belongs in a tool that reads it rather than a manifest that restates it — a URL a tool returns is allowed for the rest of that conversation, on exactly the same footing.

links: deploys with everything else, so a preview can change them without touching live, and a rollback restores the ones that were in force.

#Language

Vatio has no default language. Text addressed to the model — the platform's operating layer and safeguard repair instructions — is written in English and never reaches the visitor directly.

The handful of built-in fallback replies a visitor can see (the platform could not produce any reply at all — see Safeguards) are picked by the visitor's WhatsApp country code, in English, Spanish, or Portuguese — not by your personality or instructions, since the platform has no agent reply to draw a language from at that point.

By default the agent replies in the language the visitor writes in, so a workspace serves a multilingual audience without configuration.

To pin a language, say so in instructions or personality:

personality: Warm and brief. Always reply in Chilean Spanish, using "tú".

The one thing worth keeping consistent is your own tool messages: write them in the language the agent replies in. A tool message is a fact the agent relays as part of its reply, and a mismatched language makes for an awkward reply — nothing enforces this for you, it is just good practice.

#Features

#Knowledge bases

A knowledge base is a named, stateful store the agent reads from. It is not part of a deployment: vatio push neither fills nor empties one, and your manifest only says which bases the agent may read.

That split is the whole design. Knowledge is expensive state — crawls, chunks, embeddings — and putting it in the manifest made a push able to destroy it, and made every environment need its own copy. A base outlives every deploy, and preview and live read the same one.

vatio kb create docs
vatio kb upload docs hours.md location.md
vatio kb add-source docs site https://example.com

Then point the agent at it and give it the platform tool:

# vatio.yml
knowledge:
  - docs
agent:
  tools:
    - knowledge_lookup

knowledge: is a reference, nothing more. Adding a name lets the agent read that base; removing it stops the agent reading it and changes no content. A name that does not match an existing base is an error at push time, not an empty base wired to a live agent — the typo you would otherwise find in a bad conversation.

Deleting a base is refused while a deployed agent still references it. Unplug it in vatio.yml and push first, which makes the change reviewable in your repo.

#Sources: crawls and uploads

Everything in a base comes from a source, and there are two kinds. The difference that matters is who holds the only copy.

Kind Where the content comes from Deleting it
crawl A site Vatio re-reads. Declared with a site_url and optional path globs Recoverable — re-add the source and it crawls again
upload A file you pushed in. Nothing upstream to re-read Permanent — this is the only copy of the content

Uploads. vatio kb upload BASE FILE... sends Markdown files. The first # heading becomes the title, and the file is split into entries at its heading boundaries. The source is named after the filename, so re-uploading hours.md replaces that document in place rather than creating a second one — a typo fix changes one document instead of leaving two.

cat > hours.md <<'MD'
# Hours and location

We are open Monday to Friday 7:00–22:00, and Saturday 9:00–14:00.
We are at Av. Providencia 1234, Santiago.
MD

vatio kb upload docs hours.md

Crawls. Point the platform at a site and it indexes matching pages:

# A whole site: name it and you're done.
vatio kb add-source docs site https://example.com

# Or narrow it.
vatio kb add-source docs blog https://example.com \
  --include "/blog/**" --exclude "/blog/tag/**"

# One page, named exactly.
vatio kb add-source docs manual https://example.com --include "/docs"
Argument Required Meaning
BASE yes The knowledge base the source belongs to
NAME yes Stable id within the base — lowercase letters, digits, -/_
URL yes Must be a public http(s) URL — private/loopback/link-local addresses are rejected
--include no Path glob to index, repeatable. Omit it for the whole site
--exclude no Path glob to skip, repeatable. Checked first and wins over --include, so "everything except the blog" is --exclude "/blog/**" with no --include at all

Globs are matched against the URL path only, never the host — you name the site once. * matches within one path segment, ** matches across segments. An --include of exactly one glob-free path names one page and is indexed directly, without any discovery.

Adding a crawl starts it; there is no separate command to trigger one:

  1. Vatio works out which pages exist, taking the first of these that yields anything you asked for: the exact page, when --include names one; the links in site_url/llms.txt, a short index sites publish to say what a model should read; site_url/sitemap.xml; or, failing all of those, a same-host crawl following links up to a bounded depth and page count. Assets are skipped — only HTML and Markdown responses are indexed.
  2. Every discovered URL is filtered against --include/--exclude and robots.txt — whichever step found it, including llms.txt, which says what to read and never grants permission to read it. Vatio crawls as VatioBot, so a site can write rules about it by name — including letting it in where other crawlers are turned away.
  3. Each matching page is fetched (plain HTTP) and its main content extracted. If that comes back too thin to be real content — the signature of a client-rendered page (React/Vue/etc. with no server-side rendering), whose content only exists after the page mounts and fetches it itself — the page is instead rendered in headless Chrome, waiting for the page's own network activity to go idle rather than just for the initial HTML to load, and extracted from that instead. This only helps content that appears on its own after load: a route whose content requires a user action first (typing into a search box, clicking "load more") still indexes as empty, by design — see pages_indexed_count below for how to notice that. A page served as text/markdown skips extraction entirely: the markdown is already the content, and its first # heading is the page title.
  4. Each page is split into entries at its heading boundaries rather than stored whole, so a single long page — a one-page manual or help center — indexes as one entry per section instead of one giant entry that no search can use well. Each entry keeps its heading trail in the title and links back to its own section anchor, so a source stays one line no matter how long the page is.
  5. A crawl is not re-run on its own schedule alone: vatio kb reindex BASE NAME re-runs one, and vatio kb reindex BASE re-runs every crawl in the base. Uploads are skipped — there is nothing upstream to re-read, so replacing one means uploading the file again.

Reindexing re-runs discovery from scratch: pages that no longer match lose their entries, and pages_indexed_count resets to 0 and climbs again. It is allowed even while status is crawling, so a crawl wedged in that state can be retried. It is also a button on the base's page in the app.

Check progress with vatio kb show BASE:

$ vatio kb show docs
docs on workspace acme: entries=48
  blog [crawl]  completed  pages=12/12  entries=36  last_indexed_at=2026-09-08T19:17:29-03:00
    https://example.com  include=/blog/**
  hours [upload]  completed  pages=1/1  entries=2  last_indexed_at=2026-09-08T19:20:11-03:00
    hours.md

status: "completed" means discovery finished and every matching page was handed off for indexing — not that indexing itself succeeded. pages_discovered_count is how many URLs the source matched, and pages_indexed_count is how many actually produced entries — both count pages, so a page that indexed as twenty sections still counts once. A healthy source has the two equal (once indexing catches up, asynchronously, a little after status flips to completed); pages_indexed_count stuck at 0 with a non-zero pages_discovered_count means every page failed to extract — worth checking the URL in a browser with JavaScript enabled.

A crawl that discovers nothing at all reports status: "failed", not completed — you declared a site to index and got no knowledge from it, which is a failure however cleanly the crawl ran. last_error says which of the three causes it was, and what to do about each: the site's robots.txt disallows the crawl (see letting VatioBot in), --include/--exclude ruled out every page the site publishes, or nothing was found at the URL at all.

$ vatio kb show docs
docs on workspace merchconsciente: entries=0
  site [crawl]  failed  pages=0/0  entries=0  last_indexed_at=2026-09-13T11:04:02-03:00
    https://merchconsciente.com  whole site
    error: Nothing was indexed: robots.txt at https://merchconsciente.com
    disallows this crawl. If the site is yours, add a "User-agent: VatioBot"
    group with "Allow: /" above your "Disallow" rules -- see
    https://vatio.ai/docs#letting-vatiobot-crawl-a-site-that-disallows-crawlers
    Then run `vatio kb reindex docs site`.

A base that exists but that no deployed agent references is called out by both vatio kb and the app. It indexes, it costs embeddings, and it answers nobody — the agent replies without it and nothing reports an error, so this line is the only warning you get:

$ vatio kb
Knowledge bases on workspace acme (1):
  docs  sources=2  entries=48  (not referenced by any deployed agent)

#Deploying from GitHub

Connect a repository and Vatio deploys it for you: every pull request gets its own named preview with a link you can open, and a merge to the default branch publishes to live. No YAML to paste into your repo and no command to run.

  1. Install the Vatio GitHub App on the repository. This is consent, so it happens in a browser — a terminal cannot give it.
  2. GitHub sends you back to Vatio with the repositories it can reach. Pick which workspace each one deploys to. The branch and the location of your vatio.yml are read from the repository, so there is nothing to type.

That second step is the one that cannot be skipped or inferred, and it is worth knowing why. A push from the CLI is authorized by your own token. A push from GitHub is authorized by an installation, which says nothing about who owns the workspace. If Vatio trusted the workspace: in the repository it just downloaded, installing the App on any repository would be enough to deploy into anyone's workspace. So the mapping is a decision the workspace owner makes here, and a repository whose vatio.yml names a different workspace is refused, not quietly redirected.

The return trip is authenticated for the same reason. GitHub's post-install redirect carries an installation id and nothing that proves it is yours, so Vatio also asks GitHub who just installed — and refuses any installation that person cannot administer. Otherwise knowing an id would be enough to bind someone else's repository.

Event What happens
Pull request opened, updated, or reopened Deployed to pr-<number>, and the bot comments with the link — only if the pull request touches the workspace directory
Pull request closed That preview is torn down, merged or not
Push to the default branch Deployed, then published to live

If your repository holds more than one workspace — the layout docs encourage exactly that — set the workspace path when connecting. Left blank, Vatio looks for a single unambiguous vatio.yml and refuses to choose when it finds several, rather than deploying whichever it happened to see first.

A pull request that changes nothing under the workspace path gets no preview and no comment. Keeping the agent beside the code it serves means most pull requests are about the code, and a preview of an unchanged manifest is noise on every one of them. When Vatio cannot tell — a pull request too large for GitHub to list its files, or an API that will not answer — it deploys rather than skipping, because a missing preview is invisible from the pull request and a redundant one is merely unnecessary.

A deploy that fails still comments, with the error and the reassurance that nothing reached live. A preview that silently did not build is found out when someone asks why the link is dead.

Pushes to the default branch are not filtered this way. A push carries at most twenty commits in its payload, so a file list there can be silently incomplete — and skipping a publish to live on a truncated list is a far worse outcome than republishing an unchanged manifest.

#How search works

Search has two layers, tried in order, scoped to the bases the agent references:

  1. Semantic search — the visitor's question and every entry are embedded (OpenRouter, text-embedding-3-small) and ranked by similarity. This is what lets a question like "donde queda el local" match an entry titled "Ubicación" whose body never says "local" — no shared words required. Embedding happens in the background right after content lands, so a brand-new or just-changed entry can take a few seconds to become semantically searchable; it still answers via the keyword layer below in the meantime.
  2. Keyword search — a Postgres full-text search over title + body (Spanish stemming and stopwords), used whenever semantic search finds nothing confident enough, or isn't available yet for an entry.

What this means for how you write content:

An agent that references no base searches nothing at all, which is different from searching an empty base: it means the manifest still needs knowledge:.

Finding nothing is result: "ok" — a successful search with an empty answer.

Each result carries title and body, plus a url for entries that came from a crawled source — the page URL and the anchor of the section the entry was taken from. Your agent may only offer links a tool gave it, so that url is what lets it point someone at the exact section rather than the site root.

#Letting VatioBot crawl a site that disallows crawlers

Vatio identifies itself as VatioBot on every request it makes — the robots.txt fetch, discovery, and each page it indexes:

User-Agent: VatioBot (+https://vatio.ai/docs)

That name is there so a site can make a rule about Vatio specifically. The case that needs it: your site turns away every crawler, and you want your own agent to read it anyway. A blanket disallow is a common, deliberate thing to have in place —

User-agent: *
Disallow: /

— and it stops the crawl cold. vatio push still succeeds, because nothing is wrong with the declaration: the manifest is fine, the site just won't have us. vatio kb show is where you find out, with the source failed at pages=0/0 and robots.txt named in its error: line.

Grant access by adding a group that names VatioBot, above the blanket rule:

User-agent: VatioBot
Allow: /

User-agent: *
Disallow: /

Every other crawler still sees Disallow: /. Vatio reads the group addressed to it and crawls normally. The same shape carves out exceptions — in everywhere except one area:

User-agent: VatioBot
Allow: /
Disallow: /admin

Within the group that applies, the longest matching path rule wins and Allow takes a tie, so Disallow: /admin beats Allow: / for /admin/* only. Groups are matched to one crawler: if robots.txt names VatioBot, Vatio follows that group and the User-agent: * group no longer applies to it.

Permission belongs to the site, which is why it is granted this way and not with a flag on the source. Publishing robots.txt on a host is something only whoever controls that host can do — which is exactly the claim being made. Vatio has no setting that overrides another site's robots.txt, and there is no plan to add one: a manifest can declare any site_url, and nothing in a manifest can prove you own it.

#JavaScript tools

The filename (minus .js) is the tool key used in vatio.yml. For a tool that is nothing but "call one endpoint and relay the answer," consider a declarative HTTP tool instead — it skips the preview entirely.

// tools/check_availability.js
export const spec = {
  description: "Check whether a class has open spots.",
  when_to_use: "The visitor asks if a specific class or time is available.",
  parameters: {
    type: "object",
    properties: { class_name: { type: "string" } },
    required: ["class_name"]
  },
  access: "public"
};

export default async function checkAvailability(args, ctx) {
  const res = await fetch(`${ctx.env.ACME_API}/classes?name=${encodeURIComponent(args.class_name)}`);
  if (!res.ok) {
    return { result: "error", message: "We could not check availability." };
  }
  const data = await res.json();
  return {
    result: "ok",
    message: data.spots > 0
      ? `${data.spots} spots left in ${args.class_name}.`
      : `${args.class_name} is full.`,
    data
  };
}

Note both branches of the answer are ok — only the failed fetch is an error.

spec field Required Meaning
description yes What the tool does, for the LLM
when_to_use recommended When to call it. Falls back to description
parameters recommended JSON Schema object; type must be "object"
access no Omit or "public"; a scheme name to require authentication

access is a string only. Object forms and scopes are rejected.

Shared helpers go in lib/ and must not export a spec:

// lib/acme.js
async function acmeGet(ctx, path) {
  const res = await fetch(`${ctx.env.ACME_API}${path}`, {
    headers: { Authorization: `Bearer ${ctx.env.ACME_API_KEY}` }
  });
  if (!res.ok) return { ok: false };
  return { ok: true, data: await res.json() };
}

All lib/ and tools/ files are concatenated into one bundle at push time:

The context object passed to every tool:

Field Shape
ctx.workspace { slug, name }
ctx.environment "preview" or "live"
ctx.channel { type } — set by the runtime
ctx.contact { name, email, phone_number } — hints, not proof
ctx.auth { authenticated, scheme, subject, claims, token }
ctx.lookup Context returned by your contact_lookup provider
ctx.env Workspace secrets

An auth provider gets the same object with one addition, ctx.channel.visitor_token — the raw evidence the web channel carried. A tool never sees it: a tool's business is what follows from an identity, and handing it the credential only creates a way to leak it into a tool result the model reads. See Authentication.

A tool result carries no navigation: with one agent there is nothing to route to. result and message (plus any extra data fields) are the whole contract.

Validate with vatio tools check. There is no direct tool-invoke command — test inside vatio chat so access and authentication run through the real agent loop.

#Declarative HTTP tools

For a tool that is nothing but "call one endpoint and relay the answer," skip JavaScript: a tools/*.yml file describes the call and Vatio makes it directly, natively — no sandbox round-trip. The filename (minus .yml) is the tool key, same as .js.

# tools/check_availability.yml
description: "Check whether a class has open spots."
when_to_use: "The visitor asks if a specific class or time is available."
parameters:
  type: object
  properties:
    class_name: { type: string }
  required: ["class_name"]
access: public
request:
  method: GET
  base_url: "$env.ACME_API"
  path: /classes
  headers:
    Authorization: "Bearer $auth.token || Bearer $env.ACME_API_KEY"
  query:
    name: "$params.class_name"
respond:
  data:
    classes: "$.data"
  message:
    when_empty: "No classes matched that name."
    default: "Found {{count}} matching classes."

description, when_to_use, parameters, and access mean exactly what they do in a JavaScript tool's spec.

request describes the call:

Field Required Meaning
method yes GET, POST, PATCH, or DELETE
base_url recommended Usually $env.SOME_KEY
path yes Appended to base_url
headers no Header map
query no Query string params
body no JSON body (POST/PATCH only)
requires no Placeholder paths the call cannot go out without — see below

base_url, path, and every value under headers, query, and body can reference:

Placeholder Resolves to
$params.<name> A tool argument
$env.<KEY> A workspace secret
$auth.subject / $auth.token / $auth.claims.<key> The authenticated caller's principal
$contact.name / $contact.phone_number / $contact.email The visitor's CRM contact — see CRM

A value that is exactly one placeholder resolves to the underlying value as-is — an integer parameter stays an integer in a JSON body. A placeholder embedded in more text is substituted as a string, which is how you build an Authorization header. || between two placeholders picks the first one that actually resolves to something — the pattern for "use the visitor's own token if we minted one, otherwise the workspace's shared key":

headers:
  Authorization: "Bearer $auth.token || Bearer $env.ACME_API_KEY"

or for "use the name the agent was just told, otherwise whatever the contact already has on file" (see CRM for how a name gets onto the contact in the first place):

body:
  name: "$params.name || $contact.name"

#Requiring a placeholder

A placeholder that resolves to nothing drops its key from the request. That is what makes || work, and it is exactly wrong when the value is the one your endpoint identifies people by: the call still goes out, just without that parameter, and your API answers a different question than the one asked.

List those values under request.requires and the call fails instead:

request:
  method: GET
  base_url: "$env.ACME_API"
  path: /members
  requires: ["contact.phone_number"]
  query:
    phone: "$contact.phone_number"

The tool returns result: "error" before any HTTP request happens, with a typed error_key the agent can act on — missing_contact_phone, missing_contact_email, missing_contact_name, or missing_requirement for anything else. Pair it with request_contact_info so the agent can ask a WhatsApp visitor for the missing number and retry.

respond turns the HTTP response into a result:

A non-2xx response becomes result: "error", relaying the body's message/error_key when present, or a generic failure otherwise.

There is no scripting inside a .yml tool: no branching beyond empty/non-empty, no second call, no computed fields. That limit is deliberate — it is what lets the declarative form skip the sandbox and run natively, and what keeps it auditable at a glance. The moment you need a pre-fetch, an ownership check your API doesn't already enforce itself, or a message that depends on more than "was the list empty," write a JavaScript tool instead.

Validate with vatio tools check, same as JavaScript tools.

#CRM

Vatio keeps a contact for every visitor: one record per workspace and environment, holding name, email, and phone_number, plus the channel identities they arrived on. You do not create or manage it — the runtime does, and every tool sees it on every turn:

Where Field
JavaScript tool ctx.contact.name / .email / .phone_number
Declarative HTTP tool $contact.name / $contact.email / $contact.phone_number

Both are read-only views of the same record — neither kind of tool can write to it directly. Only four things fill a contact in, and that list is exhaustive:

  1. The channel. A WhatsApp visitor usually arrives with a verified phone number attached from the first message — the messaging platform vouches for it, nothing the visitor typed in chat is involved. Usually, because WhatsApp lets people message a business from a username instead: for those visitors Meta sends no phone number at all, and the contact arrives with phone_number empty and stays that way until they share it. A web or cli visitor also starts with an empty contact; nothing fills phone_number/email in automatically for those channels.

Write tools that read $contact.phone_number so they survive it being absent — and if the value is load-bearing, declare it under requires so the call fails loudly instead of going out without it.

  1. The platform tool identify_contact. Writes name only, from whatever the visitor says — it does not verify anything, and it cannot touch email or phone_number. It is opt-in: add it to the agent's tools: list and tell it in instructions when to call it (typically: the first time the visitor states their name). Skip it and a stated name is remembered only for the current conversation, not the next one.
   # vatio.yml
   agent:
     instructions: >-
       ...if you don't already know the visitor's name, ask for it, then call
       identify_contact with it so you remember her next time she writes...
     tools:
       - identify_contact
  1. The platform tool request_contact_info. Asks a WhatsApp visitor to share their phone number with a one-tap button, for the username case above. Also opt-in, and worth adding to any agent whose tools identify people by phone:
   # vatio.yml
   agent:
     tools:
       - request_contact_info

It writes nothing itself and returns no number: the visitor taps, and the number arrives in a later message, filling phone_number through the channel exactly as if they had written from it. So the agent asks, ends its turn, and retries whatever failed once the visitor replies — it must not wait on the result or read a number out of it. On web and cli there is no button to show and the tool says so; ask in your reply instead.

  1. Your authentication provider, through resolve's returned profile — a value your own backend looked up and is vouching for (e.g. the real name on file for the phone number you just authenticated). See Authentication.

There is no fifth way. Nothing lets a tool parse a visitor's message and write an arbitrary email or phone_number into the contact — if you need that, you're describing an authentication step (a resolve that looks the value up and returns it in profile), not a CRM update. This is why $contact.phone_number/$contact.email are safe to send onward to your own API from a declarative tool without re-verifying them: if the field is populated at all, it came from a channel that proved it or from your own backend, never from free-form chat text.

#Authentication

A scheme is declared by the thing that resolves it, and there are two of those.

identity: in vatio.yml declares a scheme Vatio resolves, by verifying a token your backend signed — nothing to write, and no call back to you. That is what a widget embedded behind your own login wants, and it is covered in Signed-in visitors on the web.

Everything below is the other kind: a scheme you resolve, in JavaScript, because answering "is this one of my users?" needs a lookup. There a scheme is a provider file: auth/member.js declares the member scheme that tools reference as access: member. There is nothing to register elsewhere — creating the file creates the scheme. Its options, all optional, go in an export const spec in that same file:

// auth/member.js
export const spec = {
  // profile_authoritative: true  // overwrite CRM fields from the provider profile
};

export async function resolve(ctx) { /* ... */ }

A provider that only exports resolve gets the defaults, so the spec is worth writing only when you are changing something. Scheme names come from the filename, so use lowercase letters, digits and _auth/member_tier.js, not auth/member-tier.js.

Authentication is channel evidence plus your provider's resolve. There is no login prompt, no OTP, and no session record: the visitor arrives on a channel carrying an identifier, you decide whether you recognize it, and if you do they stay authenticated for the rest of the conversation. The chat is the session.

That fits channels that already carry a verified identifier. A WhatsApp phone number is the common case; on the web, the evidence is a token your own page hands the widget — see Signed-in visitors on the web below. What it deliberately does not cover is letting a stranger log in through the chat: there is no password prompt and no OTP, so an action that only a verified account may take has to be gated on evidence that reached Vatio from somewhere other than the conversation.

By default resolve runs lazily, the first time a protected tool needs it (see below). Add proactive: true and a channel list to run it eagerly instead, before the first reply — useful when every visitor on that channel already carries a trusted identifier and you want the agent to know who it's talking to and use it as context from turn one, not just once it calls a tool:

// auth/member.js
export const spec = {
  proactive: true,
  channels: ["whatsapp"]
};

A denied or failed proactive resolve is silent — the chat just starts unauthenticated, exactly as if the visitor were unrecognized on their first protected tool call. Nothing about the provider itself changes: proactive only changes when the runtime calls resolve, not what it does.

The provider's one required export is its handler:

export async function resolve(ctx) {
  if (ctx.channel?.type !== "whatsapp") {
    return { status: "denied", message: "This action is available on WhatsApp." };
  }
  const user = await findUserByPhone(ctx, ctx.contact?.phone_number);
  if (!user) return { status: "denied", message: "We could not find your account." };
  return {
    status: "authenticated",
    principal: {
      subject: String(user.id),
      claims: { plan: user.plan },
      profile: { name: user.name, email: user.email },
      token: await mintScopedToken(ctx, user.id)
    }
  };
}
Status Meaning
authenticated Include principal.subject — a stable backend id, typically String(user.id)
denied Optionally include a message the agent relays to the visitor

profile fills empty CRM fields by default; profile_authoritative: true overwrites them instead.

principal.token is optional and opaque to Vatio — a credential your own backend issues during resolve (a short-lived, user-scoped API token, say) that the runtime threads through as ctx.auth.token on every later tool call in the conversation, JavaScript or declarative. Use it so a tool authenticates as this specific visitor against your backend instead of trusting a model-supplied id — see Declarative HTTP tools for the common case of a token in an Authorization header.

#Signed-in visitors on the web

The widget is usually embedded inside the product the visitor is already signed in to, and then asking who they are is absurd. Your page hands the widget a visitor token — a JWT your backend signs for the signed-in user — and Vatio verifies it against a public key you publish in the manifest. No provider file, no HTTP call, no shared secret.

Generate a key pair once. The private half stays in your app; the public half goes in the workspace directory:

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out identity.pem
openssl rsa -in identity.pem -pubout -out identity.pub
# vatio.yml
identity:
  public_key: identity.pub

That is the whole block. The key is the only thing Vatio cannot work out for itself: the algorithm follows from the key, the scheme is named visitor unless you say otherwise, and the rest is optional.

Mint the token wherever you render the page — this is the whole of the backend work:

JWT.encode({
  sub: user.id.to_s,
  name: user.name,
  email: user.email,
  exp: 12.hours.from_now.to_i
}, identity_key, "RS256")
<script async src="https://vatio.ai/vatio-widget.js"
        data-workspace="acme" data-token="vatpub_..."
        data-visitor-token="<%= vatio_visitor_token(current_user) %>"></script>

That is the entire setup. From the first reply the agent has ctx.auth.subject, your claims are in ctx.auth.claims, and name/email have filled the contact.

Key Required Meaning
public_key yes A PEM public key inside the workspace. Pushing a private key is refused, by the CLI and again by the deploy
algorithm no Derived from the key: ES256/384/512 from an EC curve, RS256 from an RSA key. Declare it only to sign with PS256/384/512 or a longer RSA hash
scheme no Defaults to visitor. The name tools reference in access: — the manifest block is the provider, so there is no auth/<scheme>.js
issuer no When set, iss is required on every token and must match. The signature already proves the token came from you; this is the second check worth having when the same key signs something else
audience no When set, aud must match — same reasoning as issuer, one level narrower
forward_token no Hands the same token to tools as ctx.auth.token, so they can call your API as this visitor
profile_authoritative no Overwrite CRM fields from the token instead of only filling empty ones

The algorithm is never read from the token's own header — that header is written by whoever sent the token, and believing it is the whole of the algorithm-confusion bug. It comes from your key or from this block, both of which are yours.

Why there is no callback. A WhatsApp number is a bare identifier: the channel proves someone holds a handset and nothing more, so whether that handset belongs to a customer is a question only your database can answer, and resolve exists to ask it. A signed token is the opposite shape — you already answered the question when you rendered the page, and put the answer inside. There is nobody left to ask. Checking the signature is the whole of the work, and it takes microseconds.

Why asymmetric only. The manifest is version-controlled, pushed, and handed back by vatio pull; it is not a secret store. An HS256 scheme would need its shared secret in there — and a shared secret is one Vatio could use to mint tokens for your users, not merely check them. With a public key the most Vatio can do with what it holds is verify. The constraint and the property we wanted turn out to be the same one, so HS256 is rejected rather than discouraged.

Three rules for the token itself:

When you do need a callback, write auth/<scheme>.js instead: a provider's resolve runs at chat start and can look up live state — a plan, a balance, a suspension — that a token minted at page render could not know. It reads the same evidence, as ctx.channel.visitor_token, and only the auth provider sees it: tools get the conclusion, ctx.auth, never the credential, so a tool cannot put it in a result the model reads. The two are alternatives, not layers; declare identity: or write the provider, not both for one scheme.

Failure is silent to the visitor, never to you. An expired token, a bad signature, a wrong issuer, or no token at all produce the same thing on screen: an anonymous conversation that works normally. The visitor is told nothing — they did nothing wrong and can do nothing about it, and the difference between those cases is only useful to someone probing.

vatio chat debug is where you find out, under identity:

"identity": { "authenticated": true, "scheme": "visitor", "subject": "4021" }
"identity": {
  "authenticated": false,
  "visitor_token": "ignored",
  "note": "The page sent a visitor token and this workspace declares nothing that reads one — add an `identity:` block to vatio.yml (or an auth/<scheme>.js) and push."
}

That second one is the mistake worth naming: data-visitor-token on the page and no identity: in the manifest. Nothing breaks — the chat works, the visitor is simply anonymous forever — which is exactly why it needs saying out loud rather than being left for you to notice by its absence. visitor_token reads absent when the page sent none and present when one arrived and did not verify.

Each identity gets its own conversation. Change the token and the widget starts a fresh chat under a fresh visitor id, so a shared computer cannot resume the previous person's conversation or land on their contact record. For a page that only learns who the visitor is after it renders — a single-page app, a session fetched over XHR — call VatioWidget.identify(token), and identify(null) on sign-out, instead of the attribute. If you are building your own UI, the same value is visitorToken on Vatio.chat(); see Building your own chat UI.

What this does not do is merge contacts across devices. The contact is still the browser that arrived; the verified claims fill in its name and email. The same person on a laptop and a phone is two contacts whose fields agree.

Protect a tool with access:

export const spec = {
  description: "Book a class.",
  access: "member",
  parameters: { type: "object", properties: {}, required: [] }
};

export default async function bookClass(args, ctx) {
  const subject = ctx.auth.subject;   // never a model-supplied user id
  // …
}

Without proactive, the first time a protected tool runs, the runtime calls resolve and caches the principal on the chat, so later tool calls in the same conversation cost nothing. If resolve denies, the tool returns result: "error" carrying your provider's message — write that message for the visitor, because it is what they will hear.

#Channels

The same runtime, agent, tools, and safeguards serve every channel. A channel only decides how messages arrive and leave — never what the agent can do.

Channel Reaches Notes
Web The widget on your site, or vatio.ai/w/<slug> Both are the same client on the same API — see The widget
WhatsApp Your number, or the shared preview See Integrations
Instagram Your account's DMs, or the shared preview See Instagram
CLI vatio chat Always preview; never delivers a real message
API POST /api/v1/<slug>/chats with channel: "api" Server to server: no browser, no visitor. See Chat endpoints

Use ctx.channel.type only where behavior must genuinely differ — to decide whether a channel is trustworthy for authentication, for instance. Everything else should be identical across channels.

#The widget

vatio.yml's widget: block is the public face of the workspace on the web — the chat bubble and the public chat page. Every key is optional; leave the block out and you get the platform defaults.

widget:
  accent_color: "#3355FF"
  logo: logo.png
  locale: es
  about: |
    Acme's support agent. Ask about orders, returns and shipping.
  allowed_origins:
    - https://acme.com
    - https://www.acme.com
Key Meaning
accent_color Hex color, #RRGGBB. Tints the bubble, the header and the send button
logo Path to a .png, .jpg, .webp or .gif inside the workspace, at most 2 MB. Shown as the avatar
about Up to 2000 characters shown to visitors on the public chat page, and on the widget's opening screen, where the first four lines fit. Omit it and neither shows an about section
locale en, es or pt — the language of the widget's own buttons and labels. Defaults to en
allowed_origins Origins allowed to embed the widget, as scheme://host with no path. List none and the widget cannot be embedded anywhere

about is visitor-facing copy, not prompt material: it is shown verbatim to whoever opens the chat and is never read by the agent. The widget opens onto it — the logo, the name and this line, above a New conversation button — so the first thing a visitor sees is who they are about to talk to. A host page that wants something shorter there than the full page of copy passes data-about on the script tag. Write the agent's behavior in instructions and its business context in the app's business summary — neither one belongs here.

locale is the language Vatio writes in, not the language the conversation happens in. It covers what the platform puts on the screen — the composer placeholder, the send and close buttons, "Connecting…", and the copy a visitor sees if a reply can't be generated — and the public chat page at /w/<workspace>. The agent itself answers in whatever language the visitor writes in, so a Spanish widget in front of an agent whose instructions are in English still holds a Spanish conversation. A page that has to differ from the workspace default — one locale of a multilingual site — passes data-locale on the script tag instead, like any other theming attribute below.

All of this is read-only wherever you look at it, because vatio push is what changes it. vatio widget prints it back along with the origins in force, the publishable tokens that exist, and the embed snippet — and vatio widget --new-token fills a fresh token straight into that snippet. The workspace's Channels page in the app carries the same snippet and the same tokens — it does not repeat the widget: block itself, which is in your own vatio.yml. Either way the snippet is:

<script async src="https://vatio.ai/vatio-widget.js"
        data-workspace="acme"
        data-token="vatpub_..."></script>

Both attributes are required. A tag with neither is treated as an install in progress and does nothing at all — no console noise on a page that hasn't been finished. The bubble only renders once the workspace turns out to have an agent deployed in that token's environment: no agent, no bubble, because a launcher that opens onto an error is worse than no launcher.

The widget waits for the host page's load event and then for the main thread to go idle before it fetches anything, so it cannot compete with the page it sits on. Pass data-eager="true" if you would rather have it immediately.

A visitor who is already signed in. Embedded inside your own product, the page knows who the visitor is and the agent should too. Add a third attribute, data-visitor-token, carrying a signed token your backend minted for that user — or call VatioWidget.identify(token) if the value only exists at runtime:

<script async src="https://vatio.ai/vatio-widget.js"
        data-workspace="acme"
        data-token="vatpub_..."
        data-visitor-token="<%= vatio_visitor_token(current_user) %>"></script>

Vatio verifies that token against a public key your vatio.yml declares — identity:, one line — so the workspace side is configuration rather than code, and the backend side is one helper that signs a JWT. Signed-in visitors on the web has both halves, and the three rules worth reading before you mint the first one.

Theming. accent_color and logo are the defaults, but a widget that ignores the page around it looks bolted on, so the host page can override every visual token:

Attribute Default
data-accent widget.accent_color from vatio.yml
data-accent-ink black or white, whichever is readable on the accent
data-surface #ffffff
data-ink #111827
data-muted #6b7280
data-line #e5e7eb
data-radius 16px
data-font a system sans stack; inherit uses the host page's font
data-scheme auto — follows the visitor's OS; light or dark to pin it
data-position right (or left) — bubble mode only
data-display bubble — the floating launcher; page fills a container instead
data-mount in page mode, a CSS selector for that container; defaults to <body>
data-locale widget.locale from vatio.yml
data-title the agent's name
data-about widget.about from vatio.yml, on the opening screen — set it to keep a long one out of a 412px panel
data-greeting a short line in the widget's language, on the empty state
data-suggestions none — `Pricing\

data-scheme only moves the panel's greys. The accent stays your brand in both schemes, except that a near-black accent is lightened on the dark one so the visitor's own messages don't disappear into the background. Anything you set explicitly — data-surface, data-ink — wins in both schemes, so a widget you have themed by hand looks the same whatever the visitor's OS says.

A page instead of a bubble. data-display="page" drops the launcher and lets the panel fill its container — for a page whose whole purpose is the chat, rather than a bubble floating over something else.

<div id="chat" style="height:100dvh"></div>
<script src="https://vatio.ai/vatio-widget.js"
        data-workspace="acme" data-token="vatpub_..."
        data-display="page" data-mount="#chat"></script>

That is exactly what vatio.ai/w/<slug> serves, so the hosted page and the bubble on your own site are one client with two presentations: a fix to markdown rendering or a reconnect lands on both. If you want something else entirely, skip the widget and build it on the SDK.

Anything you leave out keeps its default, so a partial theme is fine. For values that only exist at runtime — a theme switcher, CSS variables resolved in JavaScript — set them before the script runs instead:

<script>
  window.VatioWidget = {
    scheme: "dark",
    suggestions: ["Pricing", "Book a demo"],
    theme: { accent: "#1f2b47", surface: "#fff", ink: "#111", font: "inherit" }
  };
</script>

What it looks like to a visitor. The panel reads the way the chats people already use read, because that is the expectation they arrive with. The agent's answers render as markdown — headings, lists, links, code blocks with a copy button — laid out as a document rather than squeezed into a bubble; only the visitor's own messages are bubbles. An answer appears progressively under a caret instead of all at once, with a Stop button that paints the rest immediately. The composer is a box that grows as you type: Enter sends, Shift+Enter breaks the line. Scrolling up stops the panel from following the conversation, and a button appears to jump back down.

A visitor who has talked to you before gets a back arrow in the header, which opens every conversation they have had here — newest first, each showing what they opened with and the last thing said — and tapping one reopens it where they left off. The + starts a new conversation without losing the old one; the arrow appears the moment there is something behind the conversation on screen, so a first-time visitor never sees a way back to nothing. It is the same list the SDK exposes as Vatio.conversations(), so a hand-built UI can offer it too.

None of that is how the same agent behaves on WhatsApp or Instagram, and that is deliberate — see Reply style.

The widget is a small single-page UI in a shadow root — its styles cannot leak into your page and yours cannot leak into it — talking to the agent through the SDK. If you want a different UI entirely, skip the widget and use the SDK directly.

Secrets are the workspace's credentials — API keys, tokens, backend URLs. They are the only supported way to give a tool something you cannot commit.

vatio secrets set ACME_API_KEY sk_live_xxx
vatio secrets list      # keys only; values are never returned
vatio secrets unset ACME_API_KEY

They reach your tools as ctx.env:

const res = await fetch(`${ctx.env.ACME_API}/classes`, {
  headers: { Authorization: `Bearer ${ctx.env.ACME_API_KEY}` }
});
Property Behavior
Naming Keys must match [A-Z][A-Z0-9_]*
Visibility Write-only. secrets list returns key names and update times, never values
Timing Applied immediately, not at deploy time
Deployments Not part of a deployment: push and publish do not carry them
Rollback Not reverted by vatio rollback
Environments Shared by preview and live — there is one value per key

The last three rows are the ones that surprise people. Rotating a key takes effect on the next tool call with no deploy, and rolling a deployment back will not restore the previous secret. Because preview and live share values, pointing ACME_API at a staging backend to test also points production at it — use a separate workspace when you need separate credentials.

Never put credentials in vatio.yml, a tool file, or a knowledge base, and never return them in a tool message — messages reach the model and can reach the visitor. .vatio/config.json holds your developer token and must never be committed.

#The inbox widget

The chat widget is public by design — anyone on your page can start a conversation. The inbox widget is the opposite: a live view of every conversation the agent is having, for the people on your side who supervise it — support staff, account managers, whoever you decide should see it — embedded inside your own app rather than a page in Vatio.

<div id="vatio-inbox" style="height:100dvh"></div>
<script async src="https://vatio.ai/vatio-inbox.js"
        data-workspace="acme"
        data-mount="#vatio-inbox"></script>

There is no publishable token here, because nothing about this surface is meant to be public. Instead, vatio.yml declares an inbox: block — one field, the same shape as identity::

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out inbox.pem
openssl rsa -in inbox.pem -pubout -out inbox.pub
# vatio.yml
inbox:
  public_key: inbox.pub

The key is the only thing Vatio cannot work out for itself — the algorithm follows from it, same as identity:. Sign a token for whichever of your users is allowed to supervise this workspace's inbox right now, wherever you already decide that (your own admin flag, a role check, whatever it is):

JWT.encode({ sub: staff.id.to_s, exp: 8.hours.from_now.to_i }, inbox_key, "RS256")
<script>window.VatioInbox && VatioInbox.identify("<%= inbox_token_for(current_staff) %>");</script>

Pass data-token directly instead of calling identify() when the page already has the token at load time. Either way, a valid token grants the whole workspace's inbox — there is no narrower scope to ask for, because you already decided who gets a token before signing one. Losing that token is losing read-and-reply access to every conversation, so treat minting it with the same care as any other credential, and give it a short exp.

Attribute Meaning
data-workspace required
data-mount required — a CSS selector for the container the panel fills
data-token a signed inbox token, if known at load time
data-environment live (default) or preview — which chats to show
data-locale en, es or pt — the panel's own chrome
data-accent selected row + your own replies

Replying sends a message under the agent's name and starts a human takeover on that chat: the agent stops generating turns for it, so your reply and a generated one can't land seconds apart and read like two people arguing. Nothing ends the takeover automatically — a quiet visitor isn't the same thing as a resolved one — so hand it back explicitly with the panel's "hand back to agent" action once you're done. Everything your reply reaches — WhatsApp, Instagram, the widget — it reaches the same way the agent's own replies do; the one difference visible in the transcript is whose name is on it.

Rating a chat — 👍/👎 in the panel's toolbar, with an optional note — is also only here. The developer console's own chat view shows the same rating read-only, for debugging what already happened; it is not where you set one. Whoever is watching the conversation as it happens is who is in a position to say whether the agent handled it well, so that's who the panel asks.

No backend, no inbox: block, no problem. If your product is the agent — no app of your own for a supervisor to sign into — invite them from Channels in the Vatio console instead, by email. They get a link, sign in the same passwordless way you do (a code to that email, nothing to set up), and land straight on a hosted inbox — the same panel above, filling a Vatio page instead of a page of yours. Vatio signs their token with a key it manages for your workspace the first time anyone needs one; you never touch vatio.yml for this path. A supervisor invited this way sees exactly the inbox and nothing else — no deploy, no tools, no knowledge, no billing.

If you're building your own supervisor UI instead of using the panel, see Building your own inbox.

#Deployments and rollback

A deployment is an immutable snapshot of the manifest. push updates the preview pointer; publish moves the live pointer; rollback moves it back.

vatio push        # validate + update preview
vatio status      # preview / live state
vatio diff        # what changed vs. remote preview
vatio publish     # promote preview → live
vatio rollback    # restore previous live

push is idempotent: it creates the remote workspace the first time and reuses it afterward. There is no separate link step.

#Integrations

An integration connects a workspace to an outside system it does not own. Today that means WhatsApp and Instagram. There is nothing to declare in the manifest, and credentials always live in the app, never in git — bringing your own Meta number is the one step that happens in the Vatio app rather than the CLI.

#WhatsApp

WhatsApp runs on the same agent, tools, and safeguards as web chat. There are two ways to reach it, and you do not have to choose up front.

The shared preview — nothing to set up. Every workspace can talk to Vatio's WhatsApp number without a Meta account, a business verification, or a number of your own. Register a test phone from the CLI, confirm the 6-digit code that arrives on it, and from then on anything you send from that phone reaches your agent:

vatio whatsapp numbers add +56912345678
vatio whatsapp numbers verify +56912345678 123456

See Test phone numbers for the rest of the commands. The Vatio app does the same thing under Workspace → WhatsApp → WhatsApp Preview for people who are already in there.

Test phones Up to 5 per workspace, each usable by one workspace at a time
Verification 6-digit code, valid 10 minutes, resendable after 60 seconds
Environment Always preview — it runs your vatio push, never live
Contacts Kept separate from live, so testing never touches real customer records

The preview is for you and your team, not for visitors: only verified test phones get through, and anyone else receives an automatic reply. It is the fastest way to see your agent behave like a real WhatsApp conversation — typing, media, threading — before committing to a number.

Your own number, when you go live. Bring a Meta WhatsApp Business number and connect it with the three values on your number's page in Meta's dashboard:

vatio whatsapp connect --phone-number-id 1234 --waba-id 5678 --token EAAG…

One number per workspace. From then on real visitors reach your live deployment on your number, while the preview keeps serving preview on the shared one — the two coexist, with separate contacts, so you can keep testing after launch. The Vatio app does the same thing under Workspace → WhatsApp → Connect WhatsApp, including Meta's Embedded Signup popup if you would rather click through than copy ids.

Connecting sets up three things that fail independently, which is why vatio whatsapp reports them separately:

The access token Lets Vatio send on your number
The webhook subscription Lets Vatio receive — Meta delivers a WABA's messages only to apps it has subscribed
active Whether Vatio answers. A freshly connected number is paused

So a number can be connected, healthy, and still silent. vatio whatsapp tells you which of the three is missing, vatio whatsapp check repairs a lost subscription, and answering customers starts when you say so:

vatio whatsapp            # status, receiving, active
vatio whatsapp activate   # start answering on it

--token also reads VATIO_WHATSAPP_TOKEN, so the token need not land in your shell history.

Two behaviors worth knowing either way:

#Instagram

Instagram direct messages run on the same agent, tools, and safeguards as everything else. As with WhatsApp there are two ways in: a shared preview you can use immediately, and your own account when you go live.

The shared sandbox — nothing to set up. Every workspace can talk to Vatio's own Instagram account without connecting anything of your own. Register a test account, DM the code it gives you from the Instagram account you want to test with, and from then on your DMs from that account reach your preview deployment:

vatio instagram accounts add @yourhandle   # declare which account you'll use
# then send any DM to Vatio's sandbox account from it — you get a code back
vatio instagram accounts verify 123456     # confirm it

Same shape as vatio whatsapp numbers: declare the account, receive a code on it, confirm the code. The one step Instagram adds in the middle is that you have to write first — Meta does not let an app DM an account that has not messaged it — which is why the code only arrives after you send that first message, and why nothing happens until you do.

Declaring a handle proves nothing on its own; handles are public. The code is what proves the account is yours, because it goes to the account itself.

If you mistype the handle, nothing breaks and nothing tells you directly: you write, and you get the same generic "not registered" reply a stranger gets, because Vatio is waiting on a different handle. Check the handle matches the account you are writing from.

Up to five test accounts per workspace, always preview, never live. The Vatio app does the same thing under Workspace → Instagram.

Anyone who DMs the sandbox account without being registered gets an automatic reply telling them so — their message never reaches an agent.

Your own account, for a live agent. Connect an Instagram professional account (business or creator) and its DMs reach your live deployment:

vatio instagram connect

That opens Meta's consent screen in your browser. Approving it is the one step no command can stand in for — granting an app access to an Instagram account is a decision Meta makes a person confirm — and the CLI waits for you and then prints the result. One Instagram account per workspace, and one workspace per Instagram account.

Connecting does two things that are easy to confuse, and a live agent needs both:

The access token Lets Vatio send DMs as your account
The webhook subscription Lets Vatio receive them

An account with a working token but no subscription looks perfectly connected and answers nobody, because Instagram never delivers its messages. vatio instagram reports the two separately for exactly that reason, and vatio instagram check re-establishes the subscription if it was removed on Instagram's side:

vatio instagram          # status, receiving, token expiry
vatio instagram check    # re-check and repair the subscription

Access tokens expire after 60 days. Vatio renews yours automatically well before that, so normally this never surfaces. It matters only if renewal fails repeatedly — a token that has actually lapsed cannot be renewed, only granted again, and vatio instagram connect on the same account does that without disturbing the workspace's history.

The rest matches WhatsApp: the same 8-hour threading window, inbound media attached to the conversation, and text-only outbound replies. paced is the default reply style here — see Reply style.

#CLI

#Diagnostics

Command Purpose
vatio version CLI build and runtime versions
vatio doctor Ruby, config, workspace root, manifest, workspace, token status
vatio docs [--save [PATH]] [--refresh] This page as markdown, from the platform you deploy to
vatio issue "what broke" Tell us anything, with the last failure attached
vatio issue --template / --file PATH Send a written-up issue instead of a line

vatio docs prints to stdout, so you can pipe it straight into a coding agent's context. --save writes it to a file instead (default vatio-docs.md in the current directory) — useful inside a workspace, where an agent will actually find it.

The contract is not bundled into the CLI: it describes what the platform accepts, and the platform is always current, so a copy pinned to your installed CLI could quietly disagree with the server. What you get is fetched live and cached for a day; if the platform is unreachable you get the last copy you saw, with its age on stderr.

#Coding agents

Command Purpose
vatio mcp [--timeout SECONDS] Run a Model Context Protocol server on stdio that hands these commands to a coding agent
vatio mcp install Register that server in .mcp.json next to your vatio.yml, for a workspace created before this existed

Most of what gets built on Vatio gets built with an agent in the loop, and an agent without tools will write you an HTTP client for an API it is guessing at. vatio mcp gives it the CLI instead.

vatio init registers it for you: alongside vatio.yml it writes a .mcp.json naming vatio mcp as a stdio server. For a workspace you created earlier, run vatio mcp install once from anywhere inside it. The file is your editor's, not ours — commit it, edit it, delete it; nothing here reads it back, and a .mcp.json that already registers servers keeps them.

That location is the whole point. Claude Code looks for .mcp.json from your working directory upwards, which is the same walk the CLI does for vatio.yml, so an agent opened anywhere inside a workspace gets tools wired to that workspace, and one opened outside every workspace gets none rather than a server guessing which manifest you meant. The rule does not change here: the directory holding vatio.yml decides which remote a push goes to.

In any other MCP client, register a stdio server whose command is vatio mcp.

The agent gets one tool per command — vatio_docs, vatio_push, vatio_chat, vatio_status, vatio_secrets and the rest — each taking the arguments you would have typed, as a list. There is no shell in between, so a chat message with spaces in it is one element and needs no quoting.

A tool runs where the server was started. With the registration beside your manifest that is already the right directory; pass workspace_dir when you started the agent somewhere else, or when one repo holds several workspaces.

Five commands are deliberately missing from that list. login and logout need a browser and a person — log in from your terminal and the server uses the token you already have. update would replace the executable it is running from. issue writes to us as you. And config can print your token in full, which is not something to hand to a program that summarizes what it reads. instagram connect is out for the same reason as login: Meta's consent screen is the proof you own the account.

publish, rollback and pull are marked destructive, and chat and push as writing, so a client that asks for confirmation knows when to ask. Nothing an agent runs here touches live until it calls vatio_publish.

A wrapped command that outlives --timeout (300 seconds by default, 900 at most, or set VATIO_MCP_TIMEOUT) is killed and comes back as a timeout, so a hung push does not become a hung agent.

#Onboarding

Command Purpose
vatio login [--base-url URL] / vatio logout Device login; the token goes to ~/.vatio/config.json
vatio init [SLUG] [--name NAME] Make this directory a workspace: create the remote and write vatio.yml and .mcp.json here. Logs you in first if you are not. Without SLUG, the directory name is used

#Deploy

Run from inside a workspace — any directory at or under the one holding vatio.yml. The slug comes from that file and nowhere else.

Command Purpose
vatio tools check Validate the manifest statically; runs automatically before push
vatio push [--as NAME] Validate, create the remote if missing, update a preview. --as names which one
vatio status Preview and live deployment state
vatio diff Changed entities vs. remote preview
vatio diff --stat / --name-only / --format json / --full Output variants; --full prints knowledge bodies and tool source
vatio publish [--as NAME] Promote a preview to live. --as names which one
vatio rollback Restore the previous live deployment
vatio pull [--preview] Overwrite local files from the remote manifest. The logo is the one thing it cannot bring back — revisions store its name and digest, not its bytes — so vatio.yml comes back without widget.logo: and your next push leaves the deployed logo untouched
vatio kb List knowledge bases, and whether a deployed agent references each
vatio kb show NAME One base and its sources, with indexing status
vatio kb create NAME / vatio kb rm NAME Create a base, or delete an unreferenced one
vatio kb add-source BASE NAME URL [--include P] [--exclude P] Add a crawl and start it
vatio kb upload BASE FILE... Upload files into a base; re-uploading a filename replaces that document
vatio kb rm-source BASE NAME Delete one source and its entries
vatio kb reindex BASE [NAME] Re-crawl one source, or every crawl in the base

#Secrets

vatio secrets list
vatio secrets set KEY VALUE
vatio secrets unset KEY

#Publishable tokens

Credentials for public surfaces — the widget, or anything built on the SDK. See Building your own chat UI for what they can and cannot do.

vatio tokens list                                  # prefixes only
vatio tokens create [--environment live|preview] [--label NAME]
vatio tokens revoke PREFIX

create prints the token once, along with a ready-to-paste widget snippet, and no command prints it again — copy it where it needs to live. It is kept, encrypted, rather than hashed: a publishable token is meant to sit in public page source, so there is nothing about it to hide from you, and Vatio's own hosted chat page needs to be able to render one. It defaults to live, because a publishable token exists to put an agent in front of real visitors.

#Widget

vatio widget
vatio widget --environment preview
vatio widget --new-token

Prints the widget configuration the server actually enforces — accent colour, locale, logo, about, and the origin allowlist — next to the publishable tokens that exist, whether an agent is deployed in each environment, and the embed snippet. It is the read-back for an install: if the bubble is not appearing, this is the one command that says why.

Everything it shows comes from vatio.yml's widget: block, so it is read-only — the way to change a value is to edit the manifest and vatio push. The one thing the command can create is a token: --new-token mints one for the chosen environment and fills it into the snippet, so vatio widget --new-token is the whole install in a single command.

Widget on workspace acme

  Accent color     #3355FF
  Locale           es
  Logo             https://vatio.ai/rails/active_storage/…
  About            142 characters
  Allowed origins  https://acme.com
                   https://www.acme.com
  Agent deployed   preview yes   live yes

#Test phone numbers

Registers a phone on the shared WhatsApp preview — see WhatsApp for what the preview is. Always preview, never live.

vatio whatsapp numbers                          # list, with verification status
vatio whatsapp numbers add +56912345678         # sends a 6-digit code to that phone
vatio whatsapp numbers verify +56912345678 123456
vatio whatsapp numbers resend +56912345678      # 60-second cooldown
vatio whatsapp numbers remove +56912345678

This used to be vatio numbers, from before there was a second channel. That spelling still works and does the same thing — it prints a line pointing at the new name and carries on — so existing scripts are fine.

add is idempotent by number, so re-running a setup script converges instead of failing: a number already verified on this workspace is left alone, one still pending gets a new code, and one you already verified on another of your workspaces moves here with no second code — verification proves you hold the phone, and holding it once is enough.

The code itself arrives on the handset over WhatsApp, and there is no command that can read it: that round trip is the proof that somebody holds the phone. It is the one step in deploying an agent that a person has to complete, and add prints the exact verify line to run once the code shows up.

#WhatsApp

Connects and inspects your own WhatsApp number, which serves the live deployment — the opposite of vatio whatsapp numbers, which is preview-only. See WhatsApp.

vatio whatsapp               # status, receiving, active
vatio whatsapp connect --phone-number-id ID --waba-id ID --token TOKEN
vatio whatsapp check         # re-check; repairs the webhook subscription
vatio whatsapp activate      # start answering customers on it
vatio whatsapp deactivate    # pause it, keeping the credentials
vatio whatsapp disconnect

status reports three things that fail independently:

Line Means
status Whether the credentials work — Vatio can send
receiving Whether Meta delivers this number's messages — Vatio can receive
active Whether Vatio answers. A freshly connected number is paused

receiving: no is the quiet one: the number is connected, the credentials are fine, and every customer message is dropped before it reaches your agent. vatio whatsapp check fixes it.

connect is idempotent by number — re-sending the same --phone-number-id updates the token in place, which is how a rotated token is applied. A different number is refused rather than silently repointing which number the workspace answers on; disconnect first for that. --token also reads VATIO_WHATSAPP_TOKEN.

#Instagram

Connects and inspects your own Instagram account, which serves the live deployment — the opposite of vatio whatsapp numbers, which is preview-only. See Instagram.

vatio instagram              # status, whether it is receiving, token expiry
vatio instagram connect      # open Meta's consent screen, then wait for it
vatio instagram check        # re-check; repairs the webhook subscription
vatio instagram disconnect   # Vatio stops receiving that account's DMs

status reports two things that come apart:

Line Means
status Whether the access token works — Vatio can send
receiving Whether Instagram is delivering this account's DMs — Vatio can receive

receiving: no is the failure worth naming: the account is connected, the token is fine, and every customer message is silently dropped before it ever reaches your agent. vatio instagram check fixes it.

connect prints a URL and opens it. If the workspace is already connected, it re-grants the same account — which is how a lapsed access token is renewed — and refuses to silently swap in a different one; disconnect first for that.

Test accounts on the shared sandbox are the preview counterpart, and live under the same command — the Instagram equivalent of vatio whatsapp numbers:

vatio instagram accounts                    # list, with status
vatio instagram accounts add @yourhandle    # declare the account
vatio instagram accounts verify 123456      # confirm the code it DM'd you
vatio instagram accounts resend ID          # fresh code, 60-second cooldown
vatio instagram accounts remove ID

An account moves through three states, which list shows:

Status Means
declared Waiting for that account to DM the sandbox. No code exists yet
awaiting_code It wrote, Vatio replied with a code, you have not confirmed it
verified Its DMs reach your preview deployment

add waits for that first DM rather than returning immediately, then tells you the code has been sent. That matters when a coding agent is driving: the agent cannot see an Instagram inbox, so without the wait it has no way to know when to ask you for the code. Every command here ends with a NEXT: line naming whose turn it is — yours or the agent's.

verify takes only the code — the workspace is already in the path and the code is the one value you have in hand. resend needs the account to have written at least once; before that there is no code to resend, and it answers 422 with code not_awaiting_code.

The code is never returned by the API or printed by the CLI. It reaches the account by DM, which is the whole point: that is what proves you control it.

Once it verifies, Vatio DMs that account to say so — otherwise the code arrives on Instagram and the confirmation happens in a terminal, so from Instagram's side the conversation just stops. That message is written in your language (the locale on your Vatio account), not the account's: it is for the developer testing an agent, not for a customer.

#Preview chat

Always preview, never live, never a real message.

vatio chat "Hi"
vatio chat "Hi" --channel whatsapp --from +56912345678
vatio chat transcript --last 10
vatio chat debug --last 30
vatio chat reset
vatio chat destroy CHAT_ID
Flag Meaning
--channel Simulated origin: cli, web, whatsapp, email, instagramctx.channel.type
--from Sender identity (phone, email, ref). Evidence only — it never authenticates by itself

Changing --channel or --from starts a new conversation. Persist defaults with vatio config set channel whatsapp and vatio config set from +569….

#Config

~/.vatio/config.json, keys base_url, token, channel, from. It is per developer, shared by every workspace on the machine, and never belongs inside a repository:

vatio config show
vatio config set base_url https://vatio.ai
vatio config unset from

#Environment overrides

VATIO_BASE_URL (default https://vatio.ai), VATIO_TOKEN, VATIO_HOME (where config and the error journal live).

There is deliberately no override for the workspace: it comes from vatio.yml.

VATIO_BASE_URL also decides which platform vatio docs reads from, so a developer pointed at a local Vatio gets that Vatio's contract.

#Errors

Symptom Cause
vatio.yml must define an `agent:` block Add agent: to vatio.yml; it is the workspace agent. Use agent: false if the workspace only serves the verification API
`agent: false` serves only the phone verification API Remove the widget:, tools/, auth/ or knowledge: that came with it — none of them can run without an agent
`agent:` must be a mapping, or `false` It is either the agent's block or the literal false
agent X: instructions is required The agent needs non-empty instructions
vatio.yml: a workspace has exactly one agent Use agent:, not agents:
does not say which workspace it is Add workspace: <slug> to vatio.yml
no vatio.yml found walking up from … You are not inside a workspace — cd into one, or vatio init here
access "X" is not declared Create auth/X.js — the provider file is the scheme
auth/x-y.js: the filename is the scheme name Rename it with _ instead of -; a scheme name takes no hyphens
lib X: looks like a tool export const spec in lib/ — move it to tools/
`success:` was replaced by `result:` Old tool contract; return `result: "ok" \
request.method must be one of GET, POST, PATCH, DELETE A tools/*.yml file is missing or misspells request.method
request.path is required Every tools/*.yml needs request.path
HTTP 401 Token invalid or expired — vatio login
HTTP 403 Token has no access to that workspace
HTTP 404 Workspace does not exist

#Raising an issue

vatio issue is the whole channel: one command for "this is broken" and for "this should change". Use it whenever the tables above do not cover what you hit.

Something broke. One line, from where it broke:

vatio issue "push hangs on the tools step"

The CLI keeps the last command that failed — which one, what came back, and the request_id the server returned — and attaches it, so the issue resolves to the exact request on our side instead of a round-trip asking what you ran. It also sends your CLI version, Ruby version, platform, and the host you were talking to. No file contents, no manifest, no secrets, and no local paths.

The message can also arrive on stdin, which is the easy way to send output that confused you:

vatio push 2>&1 | vatio issue --yes

Something should change. When Vatio is working and still cannot do what you need, write it up against the template:

vatio issue --template > issue.md   # the skeleton, from this platform
# fill in every section
vatio issue --file issue.md         # or --file - to pipe it in

This shape is written for your coding agent, not for you. It already has your workspace, your tools, and this page in context — which is exactly what a good issue needs and exactly what gets lost when a human retypes the gist into a form. Point it at the template and let it write.

The skeleton is fetched rather than bundled, so it always matches what the platform checks. Every section in it is required, and a document that skipped one comes back rejected, saying which:

The issue was not accepted:
  - missing required section `## Open questions`
  - section `## Prior art` is still the template's own text — replace it with your own

That is deliberate. The sections are the issue: where you hit this in real work, what should change concretely enough to argue with, what you are unsure about, and what Vatio already does that this is closest to. An issue that answers those is worth reading; one that skips them is a wish. Nothing is required of the one-line shape — demanding four sections from someone whose push is hanging is how a platform stops hearing about hanging pushes.

Which shape you get is decided by --file, never by guessing at your content: markdown piped in without it is treated as a line you typed.

Everything that will be sent is printed first, and nothing leaves your machine until you confirm it. --yes skips the prompt for scripted use, and --no-diagnostics sends the message alone.

If the platform is unreachable — often the thing you are writing about — the issue is saved to ~/.vatio/issues/ rather than lost.

A human reads every one. You get a plain-text email from the Vatio Platform Team confirming it arrived, and another when it has been answered, both threaded under one subject so they stay in a single conversation. Replying to either reaches a person. There is no issue list to poll and nothing to check back on — the conversation happens in your inbox.

#API

The CLI is a client of this HTTP API. Use it directly to deploy from CI or to drive conversations from your own backend.

#Authentication

Every endpoint takes a user token as a bearer header:

Authorization: Bearer vat_...

Tokens come from vatio init / vatio login. One token covers every workspace you own.

Status Body error Meaning
401 invalid_token Missing, invalid, or expired token
403 workspace_forbidden Valid token without access to that workspace
404 workspace_not_found The workspace does not exist

#Device authorization

How the CLI obtains a token without a browser redirect.

POST /cli/device_authorizations

Returns device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in. Send the user to the verification URI, then poll:

POST /cli/device_authorizations/token
{ "device_code": "..." }

Poll until it returns the token. Rate limited to 30 requests per minute.

GET /cli/workspaces lists workspaces; POST /cli/workspaces creates one.

#Deploy endpoints

All scoped to a workspace slug, in the path as :slug — not at the root, so it never competes with app routes.

Method Path Purpose
GET /api/v1/:slug/deploy/status Preview and live pointers
GET `/api/v1/:slug/deploy/manifest?environment=preview\ live`
PATCH /api/v1/:slug/deploy/preview Push a manifest to preview
POST /api/v1/:slug/deploy/publish Promote preview to live
POST /api/v1/:slug/deploy/rollback Restore the previous live deployment
GET /api/v1/:slug/deploy/revisions Deployment history
GET /api/v1/:slug/deploy/secrets List secret keys (never values)
PATCH /api/v1/:slug/deploy/secrets/:key Create or update a secret
DELETE /api/v1/:slug/deploy/secrets/:key Delete a secret

The push body is { "manifest": { … }, "git_sha": "…", "environment": "pr-42" }. environment is optional and names which preview to land on, defaulting to preview; live is refused. The response echoes it back as environment, and adds share_url — the login-free link to that preview. manifest carries business, widget, agents, tools, libs, auth_providers, knowledge (a list of knowledge base names), and authentication — the same document the CLI builds from your directory. business, widget and agents are the three blocks of vatio.ymlagents keyed by the agent's slug, so the file's agent: arrives as {"main": {…}}. authentication.schemes has no file of its own: it is derived from the auth/*.js files and their specs. The response returns deployment_id, git_sha, applied_at, warnings, and preview_url — a login-gated link to the workspace's dashboard, showing what this push built next to what's still live, with one-click actions to test the widget, connect a WhatsApp preview number, or promote preview to live. A CLI should print preview_url after every push so the developer can open it straight from the terminal.

The response to a workspace's first-ever push also includes whatsapp_preview_hint: { message, url } — a one-time nudge pointing at Workspace → WhatsApp to connect a free preview number. It is omitted on every push after that, so a CLI should print it once and not repeat it.

Optionally attribute the deploy with an X-Vatio-Created-By header.

#Knowledge base endpoints

Not under /deploy, and that is the point: a knowledge base outlives every deployment, and a push neither fills nor empties one. Addressed by name — the name is what knowledge: in vatio.yml refers to.

Method Path Purpose
GET /api/v1/:slug/knowledge_bases Bases, with counts and whether anything references each
POST /api/v1/:slug/knowledge_bases Create a base — { "name": "docs" }
GET /api/v1/:slug/knowledge_bases/:name One base and its sources
DELETE /api/v1/:slug/knowledge_bases/:name Delete a base; refused while a deployed agent references it
POST /api/v1/:slug/knowledge_bases/:name/sources Add a crawl, or upload a file
DELETE /api/v1/:slug/knowledge_bases/:name/sources/:source Delete one source and its entries
POST /api/v1/:slug/knowledge_bases/:name/sources/:source/reindex Queue a fresh crawl; refused for uploads

One endpoint creates both kinds of source, because the caller is answering the same question — where does this content come from — and which kind it is follows from the body: { "filename": "hours.md", "content": "…" } is an upload, { "name": "blog", "site_url": "…", "include": [], "exclude": [] } is a crawl.

Source objects carry:

Field Meaning
name, kind Stable id within the base, and crawl or upload
site_url, include, exclude The crawl's declaration (null/empty for an upload)
filename The uploaded file's name (null for a crawl)
status pending, crawling, completed, or failed
pages_discovered_count URLs the source matched, found at the start of the crawl
pages_indexed_count Of those, how many currently produced entries — see Knowledge bases for what a gap between the two counts means
entries_count Entries currently in the base from this source, after chunking
last_crawled_at, last_error Timestamp and error message from the most recent indexing attempt, if any

#Publishable token endpoints

The same thing vatio tokens does, for anything that would rather call HTTP. Authenticated with your vat_ developer token like the rest of this API; what it returns is a vatpub_ publishable token, which is a much weaker credential (see Building your own chat UI).

GET /api/v1/:slug/publishable_tokens
→ 200 { "publishable_tokens": [
    { "prefix": "vatpub_1a2b3c4d", "environment": "live", "label": "marketing site",
      "created_at": "...", "last_used_at": "..." }
  ] }
POST /api/v1/:slug/publishable_tokens
{ "environment": "live", "label": "marketing site" }
→ 201 { "prefix": "vatpub_1a2b3c4d", "environment": "live", ..., "token": "vatpub_1a2b3c4d…" }

token appears in that response and nowhere else, ever — only the digest is stored. environment defaults to live; anything but preview or live is a 422.

DELETE /api/v1/:slug/publishable_tokens/:prefix
→ 204

Tokens are addressed by prefix, which is what index returns, so there is no id to keep. Revoking is immediate: pages using that token stop being able to start conversations, while conversations already underway keep working — they run on their own chat credential.

#Widget endpoint

What vatio widget reads: the widget configuration in force, the origins that will accept a publishable token, whether an agent is deployed in each environment, and the tokens that exist.

GET /api/v1/:slug/widget
→ 200 {
    "workspace": "acme",
    "script_url": "https://vatio.ai/vatio-widget.js",
    "accent_color": "#3355FF",
    "locale": "es",
    "locale_configured": true,
    "about": "…",
    "logo_url": "https://…",
    "allowed_origins": ["https://acme.com"],
    "agent_deployed": { "preview": true, "live": false },
    "publishable_tokens": [ { "prefix": "vatpub_1a2b3c4d", "environment": "live", … } ]
  }

Read-only, and there is no companion write endpoint on purpose: every field here is owned by vatio.yml's widget: block (see The widget), so anything written here would be reverted by the next vatio push. locale_configured is false when locale is the platform default rather than a value the manifest chose.

#Test phone number endpoints

The same thing vatio whatsapp numbers does. Registers phones on the shared WhatsApp preview, which is always the preview deployment — see WhatsApp.

GET /api/v1/:slug/test_phone_numbers
→ 200 { "test_phone_numbers": [
    { "phone_number": "+56912345678", "wa_id": "56912345678", "status": "verified",
      "environment": "preview", "verified_at": "...", "created_at": "..." }
  ] }
POST /api/v1/:slug/test_phone_numbers
{ "phone_number": "+56912345678" }
→ 201 { …, "status": "pending", "otp_sent": true }

create converges rather than conflicting, so a setup script can be re-run:

Already there Response
Verified on this workspace 200 with "already_verified": true, no code sent
Pending on this workspace 200 with "otp_sent": true, a fresh code
Verified on another of your workspaces 200 with "moved_from": "other-slug", moved, no code sent
POST /api/v1/:slug/test_phone_numbers/:wa_id/verify
{ "code": "123456" }
→ 200 { …, "status": "verified" }
POST /api/v1/:slug/test_phone_numbers/:wa_id/resend
→ 200 { …, "otp_sent": true }
DELETE /api/v1/:slug/test_phone_numbers/:wa_id
→ 204

:wa_id is the number's digits with no + or separators — 56912345678 — which is what index returns, so nothing has to be stored between calls. A number that is not registered on the workspace is a 404; a code that is wrong or expired is a 422 with code invalid_code; a resend inside the 60-second cooldown is a 429 with code otp_cooldown, a retry_after field in seconds, and a Retry-After header.

#WhatsApp account endpoints

The same thing vatio whatsapp does — your own WhatsApp number, which serves the live deployment. See WhatsApp.

GET /api/v1/:slug/whatsapp_account
→ 200 { "connected": true, "phone_number": "+56 9 1234 5678", "status": "ok",
        "receiving_messages": true, "active": true, "live_ready": true,
        "environment": "live", "subscribed_at": "...",
        "last_health_check_at": "...", "last_health_error": null }

With nothing connected the response is 200 { "connected": false }. No access token, WABA id, or phone number id is ever returned.

status says the credentials work, receiving_messages says Meta delivers the number's messages to Vatio, and active says Vatio answers on it. live_ready is all three — the field to check if you only check one.

POST /api/v1/:slug/whatsapp_account
{ "phone_number_id": "1234", "waba_id": "5678", "access_token": "EAAG…" }
→ 201 { …same shape as GET… , "reconnected": false }

All three fields are required; omitting any is a 422 with code missing_credentials. Re-sending the same phone_number_id updates the token in place and answers 200 with "reconnected": true. A different phone_number_id is a 409 with code different_number_connected — disconnect first rather than silently repointing the workspace.

POST /api/v1/:slug/whatsapp_account/check
POST /api/v1/:slug/whatsapp_account/activate
POST /api/v1/:slug/whatsapp_account/deactivate
→ 200 { …same shape as GET… }

check re-runs the health check, which also re-establishes the webhook subscription. activate and deactivate open and close the gate on answering customers, leaving the credentials untouched.

DELETE /api/v1/:slug/whatsapp_account
→ 204

Unsubscribes the WABA at Meta as well as forgetting it here, so it stops delivering messages Vatio would only drop. With nothing connected, everything except GET and POST is a 404 with code whatsapp_account_not_connected.

#Test Instagram account endpoints

The same thing vatio instagram accounts does. Registers accounts on Vatio's shared Instagram sandbox, which is always the preview deployment — see Instagram.

GET /api/v1/:slug/test_instagram_accounts
→ 200 { "test_instagram_accounts": [
    { "id": 12, "username": "acme", "igsid": "178…", "status": "verified",
      "environment": "preview", "dm_to": "vatio", "otp_code": null,
      "otp_expires_in_minutes": 30, "verified_at": "...", "created_at": "..." }
  ] }

status is declared, awaiting_code, or verified. otp_code is always null — the code reaches the account by DM, and a caller who could read it here would not need the account at all.

POST /api/v1/:slug/test_instagram_accounts
{ "username": "@acme" }
→ 201 { "id": 13, "username": "acme", "status": "declared", "dm_to": "vatio", … }

Declares which account you will test with; the handle is stored lowercased and without the @. Nothing is sent yet: Instagram does not let Vatio message an account that has not messaged it first, so the code is issued when that account DMs dm_to. Re-declaring a handle already waiting answers 200 with the same row rather than conflicting, so a setup script can be re-run. A missing handle is a 422 with code missing_username; five per workspace, a sixth is a 422; a server with no sandbox configured is a 503 with code instagram_preview_unavailable.

POST /api/v1/:slug/test_instagram_accounts/verify
{ "code": "418302" }
→ 200 { …, "status": "verified" }

Addressed by the code, not an id — the workspace is already in the path. A code that is wrong, expired, or belongs to no account waiting on this workspace is a 422 with code invalid_code.

GET    /api/v1/:slug/test_instagram_accounts/:id
POST   /api/v1/:slug/test_instagram_accounts/:id/resend_otp
DELETE /api/v1/:slug/test_instagram_accounts/:id

resend_otp mints a fresh code and DMs it again; it is a 422 with code not_awaiting_code before the account has ever written, a 429 with code otp_cooldown inside the 60-second window, and answers 200 with "already_verified": true when there is nothing to resend. An unknown id is a 404 with code test_instagram_account_not_found.

#Instagram account endpoints

The same thing vatio instagram does — your own Instagram account, which serves the live deployment. See Instagram.

GET /api/v1/:slug/instagram_account
→ 200 { "connected": true, "username": "acme", "status": "ok",
        "receiving_messages": true, "live_ready": true, "environment": "live",
        "subscribed_at": "...", "token_expires_at": "...",
        "token_expired": false, "token_expiring_soon": false,
        "last_health_check_at": "...", "last_health_error": null }

With nothing connected the response is 200 { "connected": false }. No access token or account id is ever returned.

status says the token works; receiving_messages says Instagram is actually delivering that account's DMs to Vatio. live_ready is both, and is the field to check if you only check one.

POST /api/v1/:slug/instagram_account
→ 200 { "connect_url": "https://…", "expires_in": 900,
        "connected_account": null }

Returns a URL to open in a browser; it does not connect anything by itself. Meta's consent screen is deliberately not automatable, and the browser session that completes it must be signed in to Vatio as someone who can see the workspace — the API token alone cannot grant access to an Instagram account. The link is good for 15 minutes. connected_account is non-null when the workspace already has an account connected, meaning the flow will re-grant that same one; connecting a different account requires disconnecting first.

If this Vatio deployment has no Instagram app configured, this is a 503 with code instagram_not_configured.

POST /api/v1/:slug/instagram_account/check
→ 200 { …same shape as GET… }

Re-runs the health check, which also re-establishes the messages webhook subscription — the repair for an account that stopped delivering because Vatio was removed on Instagram's side.

DELETE /api/v1/:slug/instagram_account
→ 204

Unsubscribes the account at Meta as well as forgetting it here, so it stops delivering DMs Vatio would only drop. With nothing connected, check and DELETE are a 404 with code instagram_account_not_connected.

#Chat endpoints

One surface for both environments — environment (preview or live) is a request param on the two actions that don't have a chat yet to read it from. Every other action (show, messages, reset, delete) takes only the chat id: the chat's own environment already says which one it is, so there's nothing to pass or get wrong.

Conversations are asynchronous: post a message, then poll for the reply.

POST /api/v1/:slug/chats
{ "environment": "live", "channel": "web", "from": "[email protected]", "session_id": "..." }
→ 201 { "chat_id": 42, ... }

environment defaults to live if omitted (vatio chat always passes preview). channel defaults to cli. Pass channel: "api" for a server-to-server integration — a program on both ends, no browser and no visitor — as opposed to simulating web/whatsapp/email/instagram for testing.

reply_style decides the shape of the reply, on any channel, and defaults to what that channel would do on its own: paced for whatsapp/instagram/email, stream for web, instant for cli and api. See Reply style. An unrecognised value is a 422 here — unlike the visitor API, which falls back rather than break a live widget over a cosmetic parameter. The response echoes the reply_style you got.

from is ingress evidence only and never authenticates by itself. Passing email, phone_number, or as is rejected — there is no impersonation shortcut.

If your integration is a browser, this is the wrong surface. It authenticates with a vat_ developer secret, which must never reach a page anyone can read. A chat UI belongs on the visitor API and the SDK, which authenticate with a publishable token and hand back a credential scoped to one conversation.

POST /api/v1/:slug/chats/42/messages
{ "content": "Hola" }
→ 202 { "user_message_id": 128 }
GET /api/v1/:slug/chats/42/messages?after=128&view=visitor

Poll until the assistant message appears. view=visitor returns what the visitor sees; view=debug adds tool calls, results, and routing. after and limit page the list.

This surface is poll-based. If what you're building is a chat UI in a browser, don't poll and don't use this token — see Building your own chat UI, which pushes replies over a socket and authenticates with a credential that is safe to publish.

Other operations: GET .../chats/:id (chat with messages), POST .../chats/:id/reset (start a fresh conversation — takes environment the same way create does), and DELETE /api/v1/:slug/chats/:id (delete a chat).

If the workspace has no main agent, creating a chat returns 422 with error_key missing_agent and a hint describing which command to run.

#Building your own chat UI

A public page cannot hold a developer token. A vat_ token deploys and reads customer data, and anything in a browser is readable by everyone who opens the page. So a public surface — Vatio's own widget included — uses a different credential and a different client.

Publishable tokens. Create one with the CLI — no dashboard visit, so an agent can do the whole thing:

vatio tokens create --environment live
vatio tokens list        # prefixes only; a token's full value is printed once
vatio tokens revoke vatpub_1a2b3c4d

It is also on the workspace's Channels page in the app, and over the API at POST /api/v1/:slug/publishable_tokens (see Publishable token endpoints). However you make it, it looks like vatpub_…, it is scoped to one environment, and it belongs in your page source:

In a browser, use the SDK. It is the contract; the transport underneath is not.

<script type="module">
  import { Vatio } from "https://vatio.ai/sdk/1.js";

  const chat = await Vatio.chat({ workspace: "acme", token: "vatpub_..." });

  chat.on("message", (message) => appendBubble(message));
  chat.on("typing", (isTyping) => showDots(isTyping));
  chat.on("status", (state) => {/* "connected" | "reconnecting" | "polling" */});
  chat.on("error", (error) => console.warn(error.code, error.message));

  for (const message of await chat.history()) appendBubble(message);

  await chat.send("Hola");
</script>

That is the whole API: Vatio.chat(), Vatio.config() (the agent's name, avatar, accent colour and locale, for rendering a launcher before anyone talks), Vatio.conversations() (below), chat.send(), chat.history(), chat.on(), chat.close(), and Vatio.reset() to forget the stored conversation.

Everything this visitor has asked you. A chat that only remembers the conversation in front of it makes a returning visitor retype what they already said. Vatio.conversations() returns the rest, newest first:

const past = await Vatio.conversations({ workspace: "acme", token: "vatpub_..." });
// [{ chatId, chatToken, expiresAt, environment, title, preview,
//    startedAt, updatedAt }, …]

const chat = await Vatio.chat({
  workspace: "acme",
  token: "vatpub_...",
  conversation: past[0]      // reopen that one; it becomes the stored conversation
});

title is what the visitor opened with and preview is the last thing said — between them, how a person recognises a conversation they had. The transcript is not in the list: read it with chat.history() after reopening, which is what each entry's own credential authorizes.

Identity is the visitor_ref the first Vatio.chat() stored in this browser, so this lists what this browser has been — the same rule that decides which contact a new conversation lands on. A visitor who has never talked here gets [], not an error, so a first-time page needs no branch. Conversations where nothing was ever said are left out.

A signed-in visitor. If the page already knows who this is, pass visitorToken — a token your backend signed, which Vatio verifies against the public key your vatio.yml declares:

const chat = await Vatio.chat({
  workspace: "acme",
  token: "vatpub_...",
  visitorToken: await fetchVatioToken()
});

Each token gets its own stored conversation, its own visitor id, and therefore its own history: signing someone else in never resumes — or lists — the previous person's chats. Pass the same visitorToken to Vatio.conversations(), Vatio.resumable() and Vatio.reset(), or they answer for a different person than the one on screen. See Signed-in visitors on the web for what the token has to be and how the workspace declares its key.

#Building your own inbox

The inbox widget is one UI on top of a small API and the same Vatio SDK object the chat widget uses — build your own if you want a different one.

<script type="module">
  import { Vatio } from "https://vatio.ai/sdk/1.js";

  const inbox = await Vatio.inbox({ workspace: "acme", token: supervisorToken });

  const { data: chats } = await inbox.chats();       // newest first, whole workspace
  const chat = await inbox.open(chats[0].chat_id);   // wires a socket for this one

  chat.on("message", (message) => appendBubble(message));
  for (const message of await chat.history()) appendBubble(message);

  await chat.reply("On it, give me a minute");
  await chat.flag("bad", "Agent looped on the refund question");
  await chat.release();                              // hand it back to the agent
</script>

token is the same signed inbox token from The inbox widget — not a publishable token, and Vatio.inbox() refuses one that looks like vatpub_… rather than fail confusingly later.

inbox.chats() is a plain call, not a subscription: the chat list has no realtime push in this version, so render it on open and call it again on whatever cadence your UI wants. inbox.open(chatId) is realtime — a socket for that one conversation, reconnect included, the same as Vatio.chat(). History comes back in the developer view — tool calls included — because deciding whether to step in means seeing what the agent tried, not the visitor's trimmed transcript.

Without the SDK, the same three operations are plain HTTP, authenticated with the inbox token as a bearer token throughout:

GET /api/inbox/v1/:slug/chats?environment=live&limit=30&offset=0
Authorization: Bearer <inbox_token>
→ 200 { "data": [
    { "chat_id": 42, "title": "…", "preview": "…", "source": "web",
      "started_at": "...", "updated_at": "...",
      "human_takeover": false, "human_takeover_subject": null,
      "flagged": false, "flag_verdict": null, "flag_note": null }
  ] }
GET /api/inbox/v1/:slug/chats/42/messages
Authorization: Bearer <inbox_token>
POST /api/inbox/v1/:slug/chats/42/messages
Authorization: Bearer <inbox_token>
{ "content": "On it, give me a minute" }
→ 202 { "message_id": 128 }
POST /api/inbox/v1/:slug/chats/42/release
Authorization: Bearer <inbox_token>
POST /api/inbox/v1/:slug/chats/42/flag
Authorization: Bearer <inbox_token>
{ "verdict": "bad", "note": "Agent looped on the refund question" }
DELETE /api/inbox/v1/:slug/chats/42/flag
Authorization: Bearer <inbox_token>

verdict is good or bad; note is optional. This is the only place a chat gets rated — the developer console's own chat view shows the same flag read-only, for debugging, not for setting it.

A valid inbox token authenticates against the whole workspace: there is no chat_id to include when minting it and no narrower credential these endpoints hand back, unlike the visitor API's per-chat credential. origin rules are the same as the chat widget's — the token only works from an origin your inbox:-adjacent widget.allowed_origins lists (see The widget) — because the inbox widget embeds in your app exactly the way the chat widget does.

#Reply style

The same answer should not arrive the same way everywhere. On WhatsApp and Instagram people are texting people, and one wall of text reads as a robot; on a web page people arrive having learned chat from ChatGPT, and a reply chopped into three texts reads as artificial. So the shape of a reply is a property of the channel, not of the agent — the agent writes one answer either way.

reply_style What arrives Default for
paced two or three short bubbles, each preceded by a typing event and a pause sized to how long a person would take to type it WhatsApp, Instagram, email
stream one uninterrupted answer, no splitting and no artificial delay, sent as soon as it is ready the web widget and anything on a publishable token
instant one answer, whole, and no typing events at all the vatio chat CLI, and channel: "api"

stream sends the reply as a single message. Render it progressively — a few characters per frame — and you get the experience visitors expect; the widget does exactly that. The server does not meter it out, so nothing about your UI has to wait on the network.

Markdown follows the same rule. On the web the agent writes markdown, because the widget renders it: a command or a config snippet comes back as a fenced code block with a copy button, a path or a flag as inline code, ordered steps as a numbered list. Everywhere else it writes plain text — WhatsApp and Instagram show **bold** as asterisks, and a terminal or a server reading content off the API gets the backticks verbatim. You do not configure this and the agent's instructions do not need to mention it; the channel decides, the same way it decides reply_style. An agent that answers "how do I install this" is worth reading on a web page for exactly this reason.

Override it per conversation when the default is wrong for what you are building — a support surface that should feel like a person, say:

const chat = await Vatio.chat({
  workspace: "acme",
  token: "vatpub_...",
  replyStyle: "paced"     // "stream" (default) | "paced" | "instant"
});

It is fixed when the conversation starts, so changing it affects the next new chat; pass fresh: true to start one now. An unrecognised value falls back to the default rather than failing the request — the POST /chats response echoes reply_style so you can see which one you got.

What it handles so you don't:

Pin the major version in the URL. /sdk/1.js will not change behaviour under a page that already loads it.

The socket protocol is not part of this contract. It is an implementation detail of /sdk/1.js, which is what lets it change without breaking your page. Don't reverse-engineer it; if you need push somewhere the SDK can't run, ask for it rather than building on frames you observed.

Without the SDK — a native app, a server-side integration, anything that isn't a browser running modern JavaScript — the same three endpoints are plain HTTP. Create the chat, then poll.

POST /api/public/v1/:slug/chats
Authorization: Bearer vatpub_...
{
  "visitor_ref": "optional-stable-id-for-this-visitor",
  "visitor_token": "optional-token-your-backend-minted",
  "reply_style": "stream"
}
→ 201 {
    "chat_id": 42,
    "chat_token": "...",
    "chat_token_expires_at": "...",
    "visitor_ref": "...",
    "environment": "live",
    "reply_style": "stream"
  }
POST /api/public/v1/:slug/chats/42/messages
Authorization: Bearer <chat_token>
{ "content": "Hola" }
→ 202 { "user_message_id": 128 }
GET /api/public/v1/:slug/chats/42/messages?after=128
Authorization: Bearer <chat_token>
GET /api/public/v1/:slug/chats?visitor_ref=...
Authorization: Bearer vatpub_...
→ 200 { "data": [
    { "chat_id": 42, "chat_token": "...", "chat_token_expires_at": "...",
      "environment": "live", "title": "How much is the pro plan?",
      "preview": "It is $29 a month, billed…",
      "started_at": "...", "updated_at": "..." }
  ] }

The list is the one call a publishable token makes about conversations that already exist, and only for the visitor_ref presented with it. Each entry carries a fresh chat_token, which is what opens the transcript; an unknown ref is an empty list, never a new contact.

GET /api/public/v1/:slug/config returns the agent's name, avatar, about line and accent colour, and answers 404 when the environment has nothing deployed — which is the signal to render no launcher at all.

Notes on this surface:

#Verify endpoints

Phone number verification over WhatsApp OTP, scoped to a workspace slug the same way the deploy and chat endpoints are.

These need no deployment: the workspace, an API token, and WhatsApp (your own connected number, or the shared preview one) are enough. A workspace that exists only to authenticate people in your product declares agent: false in its vatio.yml and deploys with no agent at all.

Method Path Purpose
POST /api/v1/:slug/phone_verifications Send (or resend) an OTP to a phone number
POST /api/v1/:slug/phone_verifications/:id/verify Check a submitted OTP code
POST /api/v1/:slug/phone_verifications
{ "phone_number": "+56 9 1234 5678" }
→ 201 { "verification_id": 42, "expires_at": "2026-09-06T12:10:00Z" }

Calling it again for the same phone number before the resend cooldown elapses reuses the pending verification instead of sending a new code:

→ 429 {
  "verification_id": 42,
  "expires_at": "2026-09-06T12:10:00Z",
  "retry_after_seconds": 37
}
POST /api/v1/:slug/phone_verifications/42/verify
{ "code": "123456" }
→ 200 { "verified": true }

A wrong code returns { "verified": false, "attempts_left": 4 } with status 422, and locks out after 5 attempts.

Creating a verification answers 201 as soon as the code is minted; delivery happens in the background and is retried. There is no status telling you the message reached WhatsApp, because there is nothing useful to do with it — a code still in flight looks the same, to whoever is waiting for it, as one that failed. If none arrives, request another once the cooldown passes.

Domain errors here use their own shape, not the one below — { "error": { "code": "...", "message": "..." }, "request_id": "..." } — covering already_verified (422), not_found (404), and invalid_record (422). Auth failures (missing/invalid token, unknown or forbidden workspace) still use the shared shape below, since they're handled before the request reaches this endpoint.

#Error format

Errors return a JSON body and a request_id for correlation:

{
  "error": "workspace_forbidden",
  "error_description": "Token does not have access to workspace \"acme\"",
  "request_id": "f616eaa3-..."
}

Validation errors from chat and deploy endpoints use error_key and error_message instead, with the same 4xx semantics. Verify endpoints use their own { error: { code, message } } shape — see above.