Skip to main content
Integrate the Char agent into your Rails application with Hotwire. The key is placing the agent outside your Turbo frames so it persists across navigations.

Installation

Add to your Gemfile:
# No gem needed - just load the script
Or use importmaps:
# config/importmap.rb
pin "@mcp-b/char", to: "https://unpkg.com/@mcp-b/char/dist/web-component.esm.js"

Basic Usage

Place the agent in your layout, outside Turbo frames:
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
  <meta name="id-token" content="<%= current_user&.id_token %>">
  <%= stylesheet_link_tag "application" %>
  <%= javascript_importmap_tags %>
</head>
<body>
  <%= turbo_frame_tag "main" do %>
    <%= yield %>
  <% end %>

  <!-- Agent outside turbo-frame persists across navigations -->
  <char-agent id="chat-widget"></char-agent>

  <script type="module">
    import '@mcp-b/char/web-component';

    document.addEventListener('turbo:load', () => {
      const agent = document.getElementById('chat-widget');
      const idToken = document.querySelector('meta[name="id-token"]')?.content;
      const clientId = document.querySelector('meta[name="client-id"]')?.content;

      if (agent && idToken && clientId) {
        agent.connect({ idToken, clientId });
      }
    });
  </script>
</body>
</html>

With Stimulus Controller

Create a Stimulus controller for more control:
// app/javascript/controllers/char_agent_controller.js
import { Controller } from "@hotwired/stimulus"
import '@mcp-b/char/web-component';

export default class extends Controller {
  static values = { token: String, clientId: String }

  connect() {
    if (this.tokenValue && this.clientIdValue) {
      this.element.connect({ idToken: this.tokenValue, clientId: this.clientIdValue });
    }
  }

  tokenValueChanged() {
    if (this.tokenValue && this.clientIdValue) {
      this.element.connect({ idToken: this.tokenValue, clientId: this.clientIdValue });
    }
  }
}
<!-- In your layout -->
<char-agent
  id="chat-widget"
  data-controller="char-agent"
  data-char-agent-token-value="<%= current_user&.id_token %>"
  data-char-agent-client-id-value="<%= ENV.fetch('OIDC_CLIENT_ID') %>"
></char-agent>

With Devise + OmniAuth

If using Devise with OmniAuth for SSO:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  helper_method :current_id_token

  def current_id_token
    session[:id_token]
  end
end

# app/controllers/users/omniauth_callbacks_controller.rb
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  def okta
    auth = request.env['omniauth.auth']
    session[:id_token] = auth.credentials.id_token
    # ... rest of callback
  end
end

Turbo Stream Updates

If you need to update the agent via Turbo Streams, target a wrapper:
<!-- In your layout -->
<div id="chat-widget-container">
  <char-agent id="chat-widget"></char-agent>
</div>
<!-- Turbo Stream response -->
<%= turbo_stream.replace "chat-widget-container" do %>
  <div id="chat-widget-container">
    <char-agent
      id="chat-widget"
      data-controller="char-agent"
      data-char-agent-token-value="<%= @new_token %>"
      data-char-agent-client-id-value="<%= ENV.fetch('OIDC_CLIENT_ID') %>"
    ></char-agent>
  </div>
<% end %>

SSR with Ticket Exchange

For enhanced security, exchange tokens server-side:
# app/controllers/concerns/char_ticket.rb
module CharTicket
  extend ActiveSupport::Concern

  def fetch_char_ticket
    return nil unless current_user&.id_token

    response = HTTP.auth("Bearer #{current_user.id_token}")
                   .post("https://api.usechar.ai/api/auth/ticket")

    JSON.parse(response.body) if response.status.success?
  end
end
<% ticket_auth = fetch_char_ticket %>
<% if ticket_auth %>
  <char-agent
    id="chat-widget"
    data-controller="char-agent"
    data-char-agent-ticket-value="<%= ticket_auth.to_json %>"
  ></char-agent>
<% end %>

See Also