# 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

```bash
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:

```bash
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:

```bash
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](#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:

```yaml
workspace: my-auth
agent: false
```

A workspace with `agent: false` serves **only** the
[phone verification API](#verify-endpoints) — 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.

```yaml
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.

```yaml
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

```bash
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](#whatsapp).

### Talk to it

```bash
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:

```bash
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

```bash
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

```bash
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](#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](#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:

```yaml
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`](#the-widget) 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](#javascript-tools) and [Declarative HTTP
tools](#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.**

```js
{ 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 and `message` is the answer, affirmative or
  negative. A query that ran fine and found nothing is `ok`. 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](#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` (`$contact` in a declarative tool) is a profile hint, not proof.**
  Concretely: `phone_number` only ever gets set by a channel's own verified
  identity (e.g. WhatsApp's sender number) or by your auth provider's `resolve`
  returning it in `profile` (a value *you* looked up and vouched for). `name`
  can additionally come from the platform's `identify_contact` tool, 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
  arbitrary `email` or `phone_number` from 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](#crm) for the full picture.
- `ctx.channel` is **origin evidence** set by the runtime: the channel type, and
  on the web the `visitor_token` the host page supplied. What that evidence is
  worth is decided by your `identity:` 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` (and `ctx.auth.token`, if your provider sets one) is the
  **only** identity a protected tool may act on. Never accept a `user_id`
  argument from the model; the LLM can invent one. If your backend needs to
  scope a call to "this specific visitor," mint a scoped credential in
  `resolve` and return it as `principal.token` — see [Authentication](#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](#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:

```bash
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](#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:`](#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](#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`:

```yaml
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](#javascript-tools) 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](#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`:

```yaml
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.

```bash
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:

```yaml
# 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.

```bash
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:

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

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

# One page, named exactly.
vatio kb add-source docs manual https://example.com --include "/docs"
```

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

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

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

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

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

Check progress with `vatio kb show BASE`:

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

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

A crawl that discovers nothing at all reports `status: "failed"`, not
`completed` — you declared a site to index and got no knowledge from it, which
is a failure however cleanly the crawl ran. `last_error` says which of the
three causes it was, and what to do about each: the site's `robots.txt`
disallows the crawl (see
[letting VatioBot in](#letting-vatiobot-crawl-a-site-that-disallows-crawlers)),
`--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](#named-previews) with a link you can open, and a merge to
the default branch publishes to live. No YAML to paste into your repo and no
command to run.

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

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

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

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

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

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

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

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

#### How search works

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

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

What this means for how you write content:

- **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_lookup` is 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](#declarative-http-tools) instead — it skips the
preview entirely.

```js
// 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`:

```js
// 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** `import` between workspace files — imports
  are not resolved.
- Every top-level name must be unique across `lib/` and `tools/`, 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](#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`.

```yaml
# 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`](#javascript-tools).

`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](#requiring-a-placeholder) |

`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](#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":

```yaml
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](#crm) for how a name gets onto the contact in
the first place):

```yaml
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:

```yaml
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`](#crm) 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 its `data` key, `"$.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 `message` field 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](#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_empty` applies when the first array
    value under `data` is empty (or, with no array present, when every
    plucked value is blank); otherwise `default` applies. `default` may
    contain `{{count}}`, replaced with that array's length.

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](#javascript-tools)
instead.

Validate with `vatio tools check`, same as JavaScript tools.

### CRM

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

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

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

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

   Write tools that read `$contact.phone_number` so they survive it being
   absent — and if the value is load-bearing, declare it under
   [`requires`](#requiring-a-placeholder) so the call fails loudly instead of
   going out without it.
2. **The platform tool `identify_contact`.** Writes `name` only, from whatever
   the visitor says — it does not verify anything, and it cannot touch `email`
   or `phone_number`. It is opt-in: add it to the agent's `tools:` list and
   tell it in `instructions` when to call it (typically: the first time the
   visitor states their name). Skip it and a stated name is remembered only
   for the current conversation, not the next one.

   ```yaml
   # 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
   ```
3. **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:

   ```yaml
   # 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.
4. **Your authentication provider**, through `resolve`'s returned `profile` —
   a value your own backend looked up and is vouching for (e.g. the real name
   on file for the phone number you just authenticated). See
   [Authentication](#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](#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:

```js
// 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](#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:

```js
// 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:

```js
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](#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:

```bash
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out identity.pem
openssl rsa -in identity.pem -pubout -out identity.pub
```

```yaml
# 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:

```ruby
JWT.encode({
  sub: user.id.to_s,
  name: user.name,
  email: user.email,
  exp: 12.hours.from_now.to_i
}, identity_key, "RS256")
```

```html
<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 `exp` is short enough to matter.** Page source is public
  and editable; the signature is the whole security boundary. `exp` is
  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_token` deliberately, 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`:

```json
"identity": { "authenticated": true, "scheme": "visitor", "subject": "4021" }
```

```json
"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](#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`:

```js
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](#the-widget) |
| WhatsApp | Your number, or the shared preview | See [Integrations](#integrations) |
| Instagram | Your account's DMs, or the shared preview | See [Instagram](#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](#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.

```yaml
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:

```html
<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:

```html
<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](#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\|Book a demo`, up to four, shown until the first message |

`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.

```html
<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](#building-your-own-chat-ui).

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:

```html
<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](#building-your-own-chat-ui) 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](#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](#building-your-own-chat-ui). 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.

```bash
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`:

```js
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.

```html
<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:`](#signed-in-visitors-on-the-web):

```bash
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out inbox.pem
openssl rsa -in inbox.pem -pubout -out inbox.pub
```

```yaml
# 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):

```ruby
JWT.encode({ sub: staff.id.to_s, exp: 8.hours.from_now.to_i }, inbox_key, "RS256")
```

```html
<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](#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.

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

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

## Integrations

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

### WhatsApp

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

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

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

See [Test phone numbers](#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:

```bash
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:

```bash
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

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:

```bash
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:

```bash
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:

```bash
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](#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

```bash
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](#building-your-own-chat-ui) for what they can and
cannot do.

```bash
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

```bash
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](#whatsapp) for what the preview is. Always preview, never live.

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

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

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

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

### WhatsApp

Connects and inspects your own WhatsApp number, which serves the **live**
deployment — the opposite of `vatio whatsapp numbers`, which is preview-only. See
[WhatsApp](#whatsapp).

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

`status` reports three things that fail independently:

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

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

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

### Instagram

Connects and inspects your own Instagram account, which serves the **live**
deployment — the opposite of `vatio whatsapp numbers`, which is preview-only. See
[Instagram](#instagram).

```bash
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`:

```bash
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.

```bash
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:

```bash
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" \| "error"` |
| `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:

```bash
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:

```bash
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:

```bash
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.

```http
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:

```http
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` | Deployed manifest |
| `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](#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](#building-your-own-chat-ui)).

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

```http
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.

```http
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.

```http
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](#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](#whatsapp).

```http
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": "..." }
  ] }
```

```http
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 |

```http
POST /api/v1/:slug/test_phone_numbers/:wa_id/verify
{ "code": "123456" }
→ 200 { …, "status": "verified" }
```

```http
POST /api/v1/:slug/test_phone_numbers/:wa_id/resend
→ 200 { …, "otp_sent": true }
```

```http
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](#whatsapp).

```http
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.

```http
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.

```http
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.

```http
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](#instagram).

```http
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.

```http
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`.

```http
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`.

```http
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](#instagram).

```http
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.

```http
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`.

```http
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.

```http
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.

```http
POST /api/v1/:slug/chats
{ "environment": "live", "channel": "web", "from": "user@example.com", "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](#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](#building-your-own-chat-ui), which authenticate
with a publishable token and hand back a credential scoped to one
conversation.

```http
POST /api/v1/:slug/chats/42/messages
{ "content": "Hola" }
→ 202 { "user_message_id": 128 }
```

```http
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](#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:

```bash
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](#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](#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_ref` too, 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.

```html
<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:

```js
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:

```js
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](#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](#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.

```html
<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](#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:

```http
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 }
  ] }
```

```http
GET /api/inbox/v1/:slug/chats/42/messages
Authorization: Bearer <inbox_token>
```

```http
POST /api/inbox/v1/:slug/chats/42/messages
Authorization: Bearer <inbox_token>
{ "content": "On it, give me a minute" }
→ 202 { "message_id": 128 }
```

```http
POST /api/inbox/v1/:slug/chats/42/release
Authorization: Bearer <inbox_token>
```

```http
POST /api/inbox/v1/:slug/chats/42/flag
Authorization: Bearer <inbox_token>
{ "verdict": "bad", "note": "Agent looped on the refund question" }
```

```http
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](#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:

```js
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, and `send()` 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.

```http
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"
  }
```

```http
POST /api/public/v1/:slug/chats/42/messages
Authorization: Bearer <chat_token>
{ "content": "Hola" }
→ 202 { "user_message_id": 128 }
```

```http
GET /api/public/v1/:slug/chats/42/messages?after=128
Authorization: Bearer <chat_token>
```

```http
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_ref`** is how a returning visitor maps to the same contact. Store
  what `create` returns 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_token`** is 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 your `identity:` block
  publishes, or handed to your own `auth/<scheme>.js` as
  `ctx.channel.visitor_token` if you declared one instead. A workspace that
  declares neither reads it nowhere, and `vatio chat debug` says 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](#signed-in-visitors-on-the-web).
- **Origin is required.** These endpoints exist for browsers on allowlisted
  pages; a request with no `Origin` (and no matching `Referer`) 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 `429` means 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 |

```http
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:

```http
→ 429 {
  "verification_id": 42,
  "expires_at": "2026-09-06T12:10:00Z",
  "retry_after_seconds": 37
}
```

```http
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:

```json
{
  "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.
