Overview
Discourse AI exposes an admin/API endpoint for streaming an AI Agent reply over a raw chunked HTTP response.
- Protocol: Raw chunked HTTP transfer encoding (NOT Server-Sent Events).
- Implementation: Hijacks the Rack socket to stream newline-separated JSON objects.
- Side Effects: This is not just a completion API; it creates real Discourse Private Message (PM) posts.
Endpoint Details
- Location:
plugins/discourse-ai/app/controllers/discourse_ai/admin/ai_agents_controller.rb:175-272 - Route:
POST /admin/plugins/discourse-ai/ai-agents/stream-reply.json - API Key Scope:
ai:stream_completion(registered inplugins/discourse-ai/lib/ai_bot/entry_point.rb:283-286)
Request Headers
POST /admin/plugins/discourse-ai/ai-agents/stream-reply.json
Api-Key: <your_api_key>
Api-Username: <your_username>
Content-Type: application/json
Request Body Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
agent_id |
Integer | Optional* | Identifier for the agent. |
agent_name |
String | Optional* | Alternative identifier for the agent. |
query |
String | Yes | The user’s prompt/question. |
username |
String | Required if user_unique_id is omitted |
Used incase the chat PM needs to be associated with an existing user. |
user_unique_id |
String | Required if username is omitted |
Identifies the end-user. Creates/reuses a staged user keyed by custom field ai-stream-conversation-unique-id. |
preferred_username |
String | Optional* | Username for the user (if user_unique_id is not used). |
topic_id |
Integer | Optional | Continue an existing PM conversation. |
custom_instructions |
String | Optional | Appended into the agent prompt context. |
Note: You must identify the end-user by either
username(existing Discourse user) oruser_unique_id.
Response Format
The server returns a 200 OK response with Transfer-Encoding: chunked. The stream consists of newline-separated JSON objects.
HTTP Headers
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Transfer-Encoding: chunked
Cache-Control: no-cache, no-store, must-revalidate
Connection: close
X-Accel-Buffering: no
X-Content-Type-Options: nosniff
Stream Payload Structure
- Context Chunk: Provides metadata (topic ID, bot user ID, agent ID).
- Partial Chunks: Contains the streamed text fragments.
Example Stream:
{"topic_id":42,"bot_user_id":7,"agent_id":123}
{"partial":"Hello"}
{"partial":" there"}
The client should concatenate partial fields to build the final answer.
Workflow & Side Effects
The endpoint performs the following actions during execution:
- Creates a user post with the raw
query. - Streams the AI answer via the chunked response.
- Creates the final AI reply post with the accumulated answer.
- For new PMs, may auto-title the topic.
Custom Client-Executed Tools
You can provide tool definitions to allow the model to call external tools. The server pauses the stream when a tool is called, allowing the client to execute the tool and resume.
1. Initial Request with Tools
Include custom_tools in the request body:
{
"agent_id": 123,
"query": "What's the weather?",
"user_unique_id": "external-user-42",
"custom_tools": [
{
"name": "client_weather",
"description": "Gets weather from the client runtime",
"parameters": [
{
"name": "city",
"description": "City to fetch weather for",
"type": "string",
"required": true
}
]
}
]
}
2. Tool Call Event
If the model calls a tool, the stream emits a tool_calls event and stops:
{
"event": "tool_calls",
"tool_calls": [
{
"id": "tool_1",
"name": "client_weather",
"parameters": { "city": "Austin" }
}
],
"resume_token": "..."
}
The server persists conversation state in Redis at this point.
3. Resume with Tool Results
The client executes the tool and resumes the stream:
{
"resume_token": "...",
"tool_results": [
{
"tool_call_id": "tool_1",
"content": { "temperature_c": 23 }
}
]
}
The server reloads the saved prompt state, inserts the tool result, continues generation, and streams more partial chunks.
Tool Limits
- Max custom tools: 20
- Max tool results: 20
- Max custom tool definition size: 10,000 bytes
- Max tool result content size: 100 KB
- Resume TTL: 15 minutes
- Max resume rounds: 10
Implementation References
- Controller:
plugins/discourse-ai/app/controllers/discourse_ai/admin/ai_agents_controller.rb - Streamer:
plugins/discourse-ai/lib/ai_bot/response_http_streamer.rb - Custom Tools Session:
plugins/discourse-ai/lib/ai_bot/stream_reply_custom_tools_session.rb
Test Examples
See plugins/discourse-ai/spec/requests/admin/ai_agents_controller_spec.rb for comprehensive examples:
- New streamed conversation: Lines 1248-1356
- Custom tools + resume token: Lines 1358-1448
- Parallel tool calls: Lines 1467-1590
Sample implementation
Ruby Script
require 'net/http'
require 'json'
require 'uri'
# Configuration
DISCOURSE_URL = '<your site URL>'
API_KEY = '<your API key>'
USERNAME = '<your username>'
AGENT_ID = -1 # Or use agent_name
QUERY = "Hello, how are you today?"
USER_UNIQUE_ID ='<leave empty if want the PM to be sent to the USERNAME user>'
# Helper to create the URI
uri = URI("#{DISCOURSE_URL}/admin/plugins/discourse-ai/ai-agents/stream-reply.json")
# Create the HTTP request
request = Net::HTTP::Post.new(uri)
request['Api-Key'] = API_KEY
request['Api-Username'] = USERNAME
request['Content-Type'] = 'application/json'
# Prepare the request body
body = {
agent_id: AGENT_ID,
query: QUERY,
## uncomment the below line if you want to use an existing user for the conversation. Also, `username` takes precedence over `user_unique_id` if passed.
# username: USERNAME,
## use the below field along with `user_unique_id` in order to create a new staged user. When using this, skip passing the `username`.
# preferred_username: USER_UNIQUE_ID
}
body[:user_unique_id] = USER_UNIQUE_ID unless USER_UNIQUE_ID.empty?
body = body.to_json
request.body = body
http = Net::HTTP.new(uri.hostname, uri.port)
http.use_ssl = (uri.scheme == 'https')
http.request(request) do |response|
# Check if the response is successful
if response.code == '200'
puts "Stream started successfully."
puts "Response headers: #{response.to_hash}"
puts "Streaming content:"
# Read the chunked response
response.read_body do |chunk|
# The response is newline-separated JSON objects
chunk.each_line do |line|
line = line.strip
next if line.empty?
begin
json = JSON.parse(line)
if json['topic_id']
puts "\n--- Context Received ---"
puts "Topic ID: #{json['topic_id']}"
puts "Bot User ID: #{json['bot_user_id']}"
puts "Agent ID: #{json['agent_id']}"
elsif json['partial']
# Stream the partial content
print json['partial']
elsif json['event'] == 'tool_calls'
puts "\n--- Tool Call Received ---"
puts JSON.pretty_generate(json)
# Here you would handle tool execution and resume
# For this example, we just print it
end
# puts "<new word>"
rescue JSON::ParserError => e
puts "\nError parsing JSON: #{e.message}"
puts "Raw line: #{line}"
end
end
end
puts "\n--- Stream Finished ---"
else
puts "Error: #{response.code} #{response.message}"
puts "Response body: #{response.body}"
end
end
Notes
- If you want the conversation to be in the name of a new staged user, pass the
unique_user_idandpreferred_usernameas the new desired username and skip theusernamefield. - If you want to converse using an existing user, pass that user’s
usernameand skip theunique_user_idandpreferred_usernamefields.