#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" }
ok— the tool did its job andmessageis the answer, affirmative or negative. A query that ran fine and found nothing isok. The agent relays it naturally.error— the tool could not complete: backend down, invalid arguments, missing secret. Only this engages the platform's failure handling.
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:
ctx.contact($contactin a declarative tool) is a profile hint, not proof. Concretely:phone_numberonly ever gets set by a channel's own verified identity (e.g. WhatsApp's sender number) or by your auth provider'sresolvereturning it inprofile(a value you looked up and vouched for).namecan additionally come from the platform'sidentify_contacttool, which writes whatever the visitor said — nothing double-checks that a visitor named herself correctly. There is no built-in way for a tool to write an arbitraryemailorphone_numberfrom chat text; if a field is populated, it got there through one of the paths above, never through free-form parsing of what the visitor typed. See CRM for the full picture.ctx.channelis origin evidence set by the runtime: the channel type, and on the web thevisitor_tokenthe host page supplied. What that evidence is worth is decided by youridentity:block, or by your provider when you wrote one — never by the channel itself. WhatsApp is not automatically trustworthy because it carries a phone number, and a visitor token is worth exactly as much as the signature you put on it.ctx.auth.subject(andctx.auth.token, if your provider sets one) is the only identity a protected tool may act on. Never accept auser_idargument from the model; the LLM can invent one. If your backend needs to scope a call to "this specific visitor," mint a scoped credential inresolveand return it asprincipal.token— see Authentication.
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.
#Links
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:
- Vatio works out which pages exist, taking the first of these that yields
anything you asked for: the exact page, when
--includenames one; the links insite_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. - Every discovered URL is filtered against
--include/--excludeandrobots.txt— whichever step found it, includingllms.txt, which says what to read and never grants permission to read it. Vatio crawls asVatioBot, so a site can write rules about it by name — including letting it in where other crawlers are turned away. - 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_countbelow for how to notice that. A page served astext/markdownskips extraction entirely: the markdown is already the content, and its first# headingis the page title. - 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.
- A crawl is not re-run on its own schedule alone:
vatio kb reindex BASE NAMEre-runs one, andvatio kb reindex BASEre-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.
- Install the Vatio GitHub App on the repository. This is consent, so it happens in a browser — a terminal cannot give it.
- 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.ymlare 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:
- 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. - 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:
- Write natural sentences, not keyword fragments. Semantic search matches meaning, not exact substrings, so "a visitor might ask X" phrasing helps less than it used to — but title and body both feed the embedding, so a descriptive heading (not "FAQ #3") still pulls its weight.
- One topic per section. An entry is embedded as a single unit, so a long section mixing hours, returns, and shipping dilutes its embedding and competes with itself for relevance. Prefer several focused sections — one each for hours, location, returns — over one giant catch-all.
- Write in the language your visitors actually ask in. The keyword layer is tuned for Spanish specifically (stemming/stopwords); semantic search is more language-flexible but still ranks best within one language. Content in one language will not reliably answer a question asked in another.
knowledge_lookupis scoped to reference material — hours, location, policies. It is steered away from plans, prices, and bookings. Put anything that changes, or must be correct at the moment of asking, behind a tool that reads your real system.- Neither layer is exact. Semantic search can occasionally surface an unrelated entry that happens to share a distinctive word (a place name, a product name) with the question — treat results as candidates the agent reads and judges, not guaranteed-correct answers.
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:
- Call helpers directly. Do not
importbetween workspace files — imports are not resolved. - Every top-level name must be unique across
lib/andtools/, or one silently overwrites the other.
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:
data— a map of output field name to a path into the parsed JSON body:"$"for the whole body,"$.data"for itsdatakey,"$.data.plan"for a nested field. These land as extra fields on the tool's result.message— optional, three forms:- Omitted — relay the response body's own
messagefield verbatim. Use this when your API already computes the message server-side and follows the same{ result, message }shape described in Tools and the result contract; it is the cleanest option whenever your backend is one you control. - A plain string — used as-is.
{ when_empty, default }—when_emptyapplies when the first array value underdatais empty (or, with no array present, when every plucked value is blank); otherwisedefaultapplies.defaultmay contain{{count}}, replaced with that array's length.
- Omitted — relay the response body's own
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:
- 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_numberempty and stays that way until they share it. Aweborclivisitor also starts with an empty contact; nothing fillsphone_number/emailin 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.
- The platform tool
identify_contact. Writesnameonly, from whatever the visitor says — it does not verify anything, and it cannot touchemailorphone_number. It is opt-in: add it to the agent'stools:list and tell it ininstructionswhen 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
- 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.
- Your authentication provider, through
resolve's returnedprofile— 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:
- Sign it, and check
expis short enough to matter. Page source is public and editable; the signature is the whole security boundary.expis required, because a token with no expiry is a permanent credential sitting in HTML. - Make it outlive a conversation. It is captured when the chat starts and nothing refreshes it. Twelve hours matches the chat credential's own lifetime and is a good default; a five-minute token expires mid-conversation.
- Mint it for this purpose. Reuse your session cookie's payload if you
like, but not a credential that grants anything beyond saying who this is —
unless you set
forward_tokendeliberately, because you want tools to present it to your API.
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 |
| Your number, or the shared preview | See Integrations | |
| 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 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:
- Threading. The runtime continues the visitor's most recent WhatsApp conversation unless their last message is more than 8 hours old, in which case the next message starts a fresh conversation with no prior context.
- Outbound is text-only today. Inbound media is received and attached to the conversation, but replies are plain text.
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.
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.
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, instagram → ctx.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.yml — agents 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:
- It can start a conversation on that workspace, in that environment, and —
presented together with a
visitor_ref— list the conversations that one visitor has had. It cannot deploy, read contacts, list the workspace's chats, or read a conversation. - It only works from an origin you listed in
allowed_origins(see The widget). A token lifted out of your HTML is inert anywhere else. - Creating a chat returns a chat credential: signed, scoped to that one
conversation, valid 12 hours. Sending, reading history and subscribing all
authenticate with that instead. So a publishable token that leaks — and it
is published, it always leaks — can open new conversations, which the origin
allowlist and rate limits bound, and can never read an existing one. Listing
needs the
visitor_reftoo, and that is the visitor's own, stored in their browser rather than 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:
- The ordering rule. A reply published before your subscription is live is
a reply nobody hears.
Vatio.chat()doesn't resolve until the subscription is confirmed, andsend()waits for it. - The credential. Stored per tab, reused on reload, renewed by starting a fresh chat when it expires. Your code never sees it.
- Reconnects. With backoff, and a history resync afterwards so a reply that landed mid-reconnect still arrives — deduplicated by message id, so a bubble never renders twice.
- The fallback. Where a socket can't be established at all, it polls, and
says so through
status.
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:
visitor_refis how a returning visitor maps to the same contact. Store whatcreatereturns and send it back next time; omit it and every visit is a new contact. It is namespaced per environment, so a preview surface never writes to a live contact.visitor_tokenis who your own app says this is — write-only, never echoed back and never readable from any other endpoint. Sign it, or it proves nothing: it is checked against the public key youridentity:block publishes, or handed to your ownauth/<scheme>.jsasctx.channel.visitor_tokenif you declared one instead. A workspace that declares neither reads it nowhere, andvatio chat debugsays so rather than leaving you to wonder. It is fixed for the life of the chat, so mint one that outlives a conversation and start a new chat when the signed-in user changes. Anything past 4,096 characters is truncated rather than rejected — a token that long is a mistake, and it fails where it should, at verification. See Signed-in visitors on the web.- Origin is required. These endpoints exist for browsers on allowlisted
pages; a request with no
Origin(and no matchingReferer) is refused. Preflight is answered on every route. - Rate limits are per IP per workspace: 10 chat creations a minute, 30
messages, 60 config reads, 60 conversation lists. A
429means slow down, not stop. - Messages cap at 4,000 characters. Longer is a
422, not a truncation. - Replies are always paced here — bubble-split, with typing indicators — because the caller is a person in a conversation, not a script.
#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.