# Ruby gem

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

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

An app that only signs tokens never loads the second.

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

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

## Setup

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

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

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

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

## Identify users on the web

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

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

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

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

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

## Sign in from WhatsApp and Instagram

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

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

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

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

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

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

## Verify the token in your API

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

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

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

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

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

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

## Several workspaces

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

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

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

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

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

  private

  def vatio_identity_config
    return if current_tenant.vatio_public_key.blank?

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

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

## Message people first

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

```ruby
require "vatio/server"

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

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

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

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

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

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

### Errors

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

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

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

### Background jobs

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

### Webhooks

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

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

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

### Tests

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

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

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

## What it does not do

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

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