Chatbot Development in Ruby: Exploring Key Classes

Written by

A customer asks “Where is my order?” in a Rails application. The chat window looks simple, but the application still has work to do: identify the customer, find an order they are allowed to see, and provide a useful answer if the delivery service is down.

Ruby chatbot development involves designing an application. A few small objects can keep track of incoming messages, conversation history and reply logic. Those boundaries give a language-model client its own place, rather than scattering API calls across the application.

The next sections build a small chatbot that works independently of Rails, and then describe what changes occur when we add a real service.

Determine a task for the bot to accomplish

“Answer customer questions” is too vague. “Display delivery status of signed-in customer’s latest order” gives us something to implement and test.

Decide what questions the first version of the bot will respond to. What information does the bot require? At what point will the bot pass control over to a human?

If you’re using Rails for your application, the bot can reuse existing business rules and authorisation checks.

A chatbot development company can assist with channel integrations or conversation design. Regardless, the contract should clearly define ownership of the codebase, how access will be granted, what will happen in the event of failure, and who is responsible for maintaining the service.

When there are just a few commands that are supported by an explicit ruleset, this approach typically works well. However, when the user’s wording around the question varies significantly or when the application must interpret larger pieces of text, a language model may help. In some cases both of these approaches can exist together in the same Ruby application.

Give each class one role

In other words, each class should clearly have a purpose and it should be apparent which class contains the source of a particular piece of output. The following five classes represent a good place to start:

NameFunctionality
MessageCarry the message role and contents.
ParserTranslate recognized text to an intent.
ConversationStateStore a limited portion of recent history.
ResponseGeneratorGenerate a reply based on the identified intent.
ChatbotCoordinate parsing, replying and updating history.

A channel adapter belongs outside this core. It translates the platform’s incoming payload into the application’s input format and delivers the reply back.

There should be no need for additional classes for different types of messages initially. A role attribute is sufficient for this example. Additional structure will be required when different message types truly require different behavior.

If you’re unfamiliar with the syntax of Ruby classes, Ruby-Doc Learn’s Ruby Code Examples include classes, methods, hashes, and regular expressions.

A small Ruby chatbot example

Save this as chatbot.rb, and run it with ruby chatbot.rb. This example uses Ruby 3.x syntax and doesn’t rely on any additional gems.

Message = Struct.new(:role, :content, keyword_init: true)

class Parser
  def parse(text)
    case text.strip.downcase
    when "hello", "hi" then :greeting
    when "help" then :help
    when "bye" then :farewell
    else :unknown
    end
  end
end

class ConversationState
  MAX_MESSAGES = 20

  def initialize
    @history = []
  end

  def empty?
    @history.empty?
  end

  def add(message)
    @history << message
    @history.shift while @history.length > MAX_MESSAGES
  end
end

class ResponseGenerator
  def generate(intent, state:)
    case intent
    when :greeting
      state.empty? ? "Hello! Type help to see my commands." : "Hello again!"
    when :help
      "Try hello, hi or bye."
    when :farewell
      "Goodbye!"
    else
      "I don't recognise that command. Type help."
    end
  end
end

class Chatbot
  def initialize
    @parser = Parser.new
    @state = ConversationState.new
    @responses = ResponseGenerator.new
  end

  def reply(text)
    unless text.is_a?(String) && text.valid_encoding?
      raise ArgumentError, "Message must be valid text"
    end
    raise ArgumentError, "Message is too long" if text.length > 1_000

    intent = @parser.parse(text)
    response = @responses.generate(intent, state: @state)

    @state.add(Message.new(role: :user, content: text))
    @state.add(Message.new(role: :assistant, content: response))
    response
  end
end

bot = Chatbot.new
puts bot.reply("  Hi  ")
puts bot.reply("help")
puts bot.reply("hello")
puts bot.reply("Where is my order?")

Expected output:

Hello! Type help to see my commands.
Try hello, hi or bye.
Hello again!
I don't recognise that command. Type help.

The greeting changes because the same bot object retains history between calls. The response generator reads the earlier state before the current exchange is added.

Our parser recognizes entire commands once whitespace has been trimmed and the text has been converted to lowercase. Our parser cannot recognize “where is my order”. We provide a clear fallback as part of our behavior.

Exact matches will be useful when working with a finite set of commands. “Hi” could also trigger against “shipping” due to looser searching for those two letters. Ruby’s Regular Expression Documentation documents matching options if you decide to move towards patterns later.

What conversation state actually does

We’ve created an array to hold message history. The response logic needs to read this array to cause an answer to vary depending upon history. History causes our greetings to differ. It does not allow our bot general knowledge about prior messages.

We limit our history to 20 messages or 10 full exchanges in this example. We do so to prevent this history from growing infinitely. That cap is a limit for this example, rather than a retention policy for a production service.

History will be lost when we restart the Ruby process. In Rails, we’d want to store messages against an authorized conversation object when persistence is necessary. We’ll need to figure out how to manage multiple requests simultaneously in terms of ordering. If we make a single global bot object available to all customers (and thus share their conversations), we will confuse their histories. Creating a new object per request will result in losing any prior state unless we retrieve it from storage.

For model-backed replies, decide which earlier messages are relevant and how much context fits the provider’s limits. Storing large transcripts in databases doesn’t mean all entries belong in every API call.

Connect the bot to Rails

To begin with, try using an ordinary HTTP endpoint if users only send in a message and expect a single complete response back from your interface.

Once your interface needs live updates in an open chat window, Action Cable provides Rails’ WebSocket integration. Action Cable manages communications between your browser clients and server-side channels; however it does not parse user input.

Handle authentication, input validation and delivery in your controller or channel. Use a service object for your conversation logic. That lets you reuse the same logic from your browser chat, an internal tool or another messaging channel.

Authenticate the customer and check their permission to access the specific conversation before accepting messages or subscriptions to its updates. Customer-provided IDs identifying conversations serve no purpose other than as lookups and are not valid proofs that you have authority to view them.

For slower external work, Active Job provides a framework for running background jobs. Many designs save incoming message records to database tables and schedule Active Job jobs to generate the reply. Once finished, you can have your application deliver the reply to the authorized conversation through Action Cable or another delivery method.

This design will require worker processes and a functioning queue backend. Be sure to plan for retry attempts; duplicate replies or repeated business actions should never occur when repeating a failed job attempt.

Put AI behind a small interface

Give the model client a clear entry point, such as generate(messages:). Keep credentials, timeouts, provider-specific parameters and response parsing inside that client. Within your application you can now choose between rules based replies and replies generated by models without affecting how you’ve coded sending messages from your browser.

Before adding a gem, check its official repository and package documentation. Confirm support for your currently deployed Ruby versions, current activity, and APIs exposed by your provider that are being wrapped by that gem. Older chatbots or NLP gems do not guarantee compatibility with newer deployments.

Model output should be treated as untrusted input. If your bot can fetch orders or suggest refunds, expose a small set of explicitly defined operations. Validate their inputs and enforce permission checks in Ruby before executing them.

A generated sentence stating a refund was successful is not equivalent to a receipt for the transaction. Do not report success until you have confirmed that the operation executed successfully.

Check the conversations that are likely to break

Typically the first things you’ll want to test are standard user behaviors:

  • Message with leading/trailing spaces or mixed case.
  • Empty message or unsupported question.
  • Second greeting in the same conversation.
  • New conversation that must not retain another customer’s history.
  • Provider timeout or duplicated job execution.
  • Requested order is owned by another user.

Use deterministic assertions for your command parsing and business logic. Assess whether the generated reply uses the supplied evidence, handles ambiguity and avoids inventing completed transactions.

Store enough runtime logging information to diagnose failures: request identifier(s), time spent processing, type of error encountered, and state of reply generation at that moment. Decide separately whether logs containing full message text are appropriate, particularly when prior conversations contain personally identifiable information.

Build a bot with limited capabilities that you can fully articulate. Once message handling, state and permissions are dependable, adding another channel or a model client becomes a smaller change.