Skip to content

Repository files navigation

ask-ag-ui

Gem Version

The AG-UI (Agent-User Interaction) protocol server for the ask-rb ecosystem — SSE event streaming and the runtime surface that assistant-ui and CopilotKit frontends expect.

Installation

gem "ask-ag-ui"

Quick Start

require "ask-ag-ui"

Ask::AGUI::VERSION  # => "0.1.0"

The seam: Ask::AGUI::Emitter

The emitter turns ask-rb agent/session events into AG-UI protocol frames. A transport drives one emitter per run: it hands over the AG-UI run context, feeds it agent events, and writes the SSE frames it answers to the stream. The transport owns the socket — the emitter only translates and encodes.

require "ask-ag-ui"

messages = [AgUiProtocol::Core::Types::UserMessage.new(id: "u1", content: "Hi")]
emitter = Ask::AGUI::Emitter.new(thread_id: "t1", run_id: "r1", messages: messages)

emitter.handle(turn_start_event).each { |frame| stream.write(frame) }
# => ["data: {\"type\":\"RUN_STARTED\",...}\n\n", ...]

emitter.finish.each { |frame| stream.write(frame) }
# => ["data: {\"type\":\"RUN_FINISHED\",...}\n\n"]

The vocabulary, matched by class name so the emitter never depends on ask-agent internals:

Agent event AG-UI events
TurnStart RUN_STARTED
TextDelta TEXT_MESSAGE_START → TEXT_MESSAGE_CONTENT → TEXT_MESSAGE_END (only empty deltas dropped — a space is content)
ThinkingDelta REASONING_START → REASONING_MESSAGE_START → REASONING_MESSAGE_CONTENT → REASONING_MESSAGE_END → REASONING_END (empty deltas dropped)
ToolCallDelta, ToolExecutionStart, ToolExecutionEnd TOOL_CALL_START → TOOL_CALL_ARGS → TOOL_CALL_END → TOOL_CALL_RESULT (empty args deltas dropped)
SessionEnd / #finish RUN_FINISHED
Error / #fail RUN_ERROR
anything else one generic CUSTOM passthrough (name + value), named after the event's class — rename it

Every frame is built with AgUiProtocol::Core::Events::* and encoded with AgUiProtocol::Encoder::EventEncoder — event JSON is never hand-rolled.

Naming your own CUSTOM frames

A host whose states are not event class names — a chat page reading resting, done, visitor_spent — says so through the public #custom_name hook, never by reaching into a private method. Name the kinds you know with the custom_names: constructor option:

emitter = Ask::AGUI::Emitter.new(
  thread_id: "t1", run_id: "r1",
  custom_names: { "VisitorAway" => "resting", "CheckoutClosed" => "visitor_spent" }
)

or override the method when the name is computed:

class ChatEmitter < Ask::AGUI::Emitter
  NAMES = { "VisitorAway" => "resting" }

  def custom_name(event)
    NAMES.fetch(event.class.name.split("::").last) { super }
  end
end

Either way only the frame's name changes: the event still rides one CUSTOM frame with its to_h as the value, and the vocabulary above is untouched. Events nobody names keep the class-name default, so the passthrough is exactly what it is without any of this.

Mounting: Ask::AGUI::Server

The server is the conventional Rack surface AG-UI clients expect. The host owns the agent and answers agent events; the gem owns the socket, the framing, and one Emitter per run. Plain Rack 3 with an enumerable body — it runs under any Rack server, with no Rails, ask-agent, or async dependency.

require "ask-ag-ui"

app = Ask::AGUI::Server.new(agent_id: "default") do |run|
  # run.thread_id, run.run_id, run.messages, run.tools,
  # run.context, run.forwarded_props — answer agent events:
  [TurnStart.new, TextDelta.new(content: "Hello")]
end

Rackup:

# config.ru
require "ask-ag-ui"

run Ask::AGUI::Server.new(agent_id: "default") { |run| MyAgent.events_for(run) }

Rails:

# config/routes.rb
mount Ask::AGUI::Server.new(agent_id: "default") { |run| MyAgent.events_for(run) },
  at: "/api/copilotkit"

Routes: GET /info, POST /agent/:id/run (SSE), POST /agent/:id/connect (replays recorded frames, or an immediately-completed empty stream when there is nothing to replay), POST /agent/:id/stop/:thread_id (JSON ack). Malformed run input answers 400 with a JSON error body.

One curl example (run a thread through the stub above):

curl -N -X POST http://localhost:9292/agent/default/run \
  -H 'Content-Type: application/json' \
  -d '{"threadId":"t1","runId":"r1","messages":[{"id":"u1","role":"user","content":"Hi"}],"tools":[],"context":[]}'

The in-memory run store keeps frames in this process only — replay and stop need a shared store once you run more than one process.

Development

bundle install
bundle exec rake test

The suite validates every emitted SSE frame against the AG-UI protocol's canonical JSON Schema, vendored at test/fixtures/ag_ui.json (generated from the reference Python SDK; the copy came from the reference implementation's data/ag_ui.json). See test/fixtures/README.md for provenance and refresh instructions. The fixture resolves through a repo-relative path, so a fresh clone needs nothing outside the repo — and a missing fixture fails loudly (ENOENT) rather than skipping validation.

License

MIT

About

AG-UI protocol server for ask-rb: SSE event streaming, run loop, and the runtime surface assistant-ui / CopilotKit frontends expect

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages