0.0
The project is in a healthy, maintained state
Emits the AI SDK UI message stream protocol from RubyLLM chats, so React front ends built with useChat talk to a Ruby backend: text, reasoning, tool calls and approvals.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 1.16
 Project Readme

ruby_llm-ai_sdk

Gem Version CI

Stream RubyLLM chats to the AI SDK useChat hook.

The AI SDK's React hooks speak the UI message stream protocol: Server-Sent Events that carry text deltas, reasoning, tool calls, tool results and approval requests. This gem emits that protocol from a RubyLLM chat, so a React or Next.js front end talks to a Ruby backend the same way it talks to a Node one, tool calls and human approvals included.

RubyLLM::AiSdk::Stream.new(chat, response.stream).ask "Refund order 42"

Installation

gem "ruby_llm-ai_sdk"

Requires RubyLLM 1.16 or later. Approval requests need RubyLLM's requires_approval, unreleased at the time of writing, so point the ruby_llm gem at its main branch to use them.

Usage in Rails

Include the controller module and hand stream_chat a chat. It sets the headers the AI SDK expects, streams the turn over ActionController::Live, and closes the stream when the turn ends.

class MessagesController < ApplicationController
  include RubyLLM::AiSdk::Controller

  def create
    stream_chat SupportAgent.find(params[:chat_id])
  end
end

On the front end, point useChat at that action. Nothing else changes.

const { messages, sendMessage, addToolApprovalResponse } = useChat({
  transport: new DefaultChatTransport({ api: `/chats/${chatId}/messages` }),
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});

stream_chat reads the body useChat posts. When the last message is from the user, it asks it, with any attached files passed to chat.ask as attachments. When the last assistant message carries approval decisions, it records them with chat.approve or chat.deny and continues the parked turn.

examples/ holds a complete pair, a Rails API app and a Next.js page, that exercises text, tools, approval and denial through a browser.

Usage anywhere else

Stream writes to anything that responds to write, so it works with Rack hijacking, Sinatra streaming, or a file.

stream = RubyLLM::AiSdk::Stream.new(chat, io)
stream.ask "What is the weather in Paris?"   # one turn, streamed
stream.serve request_body                     # a full useChat body, as JSON or a Hash

RubyLLM::AiSdk::HEADERS holds the response headers to set.

What the stream contains

RubyLLM event AI SDK chunks
Turn begins start
First chunk of a model response start-step
Text chunks text-start, text-delta, text-end
Reasoning chunks reasoning-start, reasoning-delta, reasoning-end
Assistant message with tool calls tool-input-available per call, then finish-step
Tool result message tool-output-available, or tool-output-denied after a denial
Turn parked on requires_approval tool-approval-request per pending call
Turn ends finish, then [DONE]
Exception error, then [DONE], and the exception is re-raised

Tool outputs that are valid JSON arrive parsed, so part.output on the client is the object your tool returned.

Human in the loop

Declare a tool with requires_approval and the turn stops before it runs. The client receives tool-approval-request, renders it with the tool's input, and the person decides:

{part.state === "approval-requested" && (
  <button onClick={() => addToolApprovalResponse({ id: part.approval.id, approved: true })}>
    Approve
  </button>
)}

useChat posts the decision back, the gem records it, and the same stream format carries the tool's output and the model's reply. With acts_as_chat, the decision persists on the tool call record, so the parked turn can be resumed from another process.

Design notes

  • A Stream is one turn. It registers a callback on the chat it wraps and only writes while its own turn runs, so a chat can be streamed turn after turn, with one Stream per request.
  • Tool results come from the transcript, not the callback. RubyLLM's after_tool_result does not carry the tool call id, so results are read from the tool messages appended to the chat. That also keeps concurrent tool execution correct.
  • Steps follow model responses. Each assistant message closes a step; a text or reasoning chunk that arrives with no step open opens one.

Files

useChat sends file parts as data URLs by default, or as hosted URLs when you upload them yourself. Both reach chat.ask through with:: hosted files as their URL, data URLs decoded into a RubyLLM::Attachment that keeps the filename, so RubyLLM detects the type and sends it the way the provider expects.

Development

bundle install
bundle exec rake   # specs and RuboCop

The specs stub the provider and drive real RubyLLM::Chat objects through every path, including approvals. No API keys are needed.

License

MIT.