ruby_llm-ai_sdk
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_resultdoes 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.