Insites instances support WebSocket channels for live updates. A browser opens a WebSocket connection and subscribes to a channel and a room. Your instance can then send a JSON message to everyone in that room, from a page or from a background job.
The most common use is showing the result of slow work without making the visitor wait on a page request. A page queues a background job, the job calls a slow service, such as an AI model, and sends the answer to the visitor's room. See the full example below.
Open the connection to
const socket = new WebSocket( 'wss://' + location.host + '/websocket?authenticity_token=' + encodeURIComponent(token), ['actioncable-v1-json'] );
Render the token into the page with
When the connection opens, the instance sends a
socket.send(JSON.stringify({
command: 'subscribe',
identifier: JSON.stringify({ channel: 'replies', room_id: room })
}));
The instance answers with
Send a message to everyone in a room on a channel with the
The mutation answers dispatched. A hash you pass as the payload arrives in the browser as a JSON object.
A refused subscription receives a
This example asks a slow service a question and shows the answer when it is ready. The page returns at once; a background job makes the call, which may take up to 180 seconds, and sends the answer to the visitor's room.
We ran it on an instance with the service address pointed at a test service that answers after 8 seconds. The answer appeared 9.6 s after the visitor pressed Ask. A made-up room ID and an unsigned room ID were both refused.
It needs two constants: REPLY_ROOM_SECRET, any long random string, and ANSWER_SERVICE_KEY, the API key for your service. Set each with
app/views/pages/ask.liquid issues a signed room, keeps it in the session, renders the form and subscribes. The Ask button turns on once the subscription is confirmed.
---
slug: ask
---
{% liquid
assign room = context.session.reply_room
if room == blank
assign id = '' | uuid
assign signature = id | compute_hmac: context.constants.REPLY_ROOM_SECRET
assign room = id | append: '.' | append: signature
session reply_room = room
endif
%}
<form id="ask-form">
<label for="question">Your question</label>
<input id="question" name="question" required>
<button type="submit" disabled>Ask</button>
</form>
<p id="reply" aria-live="polite"></p>
<script>
const token = {{ context.authenticity_token | json }};
const identifier = JSON.stringify({ channel: 'replies', room_id: {{ room | json }} });
const form = document.getElementById('ask-form');
const reply = document.getElementById('reply');
const socket = new WebSocket(
'wss://' + location.host + '/websocket?authenticity_token=' + encodeURIComponent(token),
['actioncable-v1-json']
);
socket.onmessage = function (event) {
const msg = JSON.parse(event.data);
if (msg.type === 'welcome') {
socket.send(JSON.stringify({ command: 'subscribe', identifier: identifier }));
} else if (msg.type === 'confirm_subscription') {
form.querySelector('button').disabled = false;
} else if (msg.identifier === identifier && msg.message) {
reply.textContent = msg.message.text || msg.message.error;
}
};
form.addEventListener('submit', function (event) {
event.preventDefault();
reply.textContent = 'Working on it...';
fetch('/ask-start', {
method: 'POST',
headers: { 'Accept': 'application/json' },
body: new URLSearchParams({ question: form.question.value, authenticity_token: token })
});
});
</script>
app/views/pages/ask-start.liquid queues the job. The room comes from the session, not from the request, so a visitor can only queue work for their own room.
---
slug: ask-start
method: post
format: json
layout: ''
---
{% liquid
assign room = context.session.reply_room
if room == blank or context.params.question == blank
response_status 422
echo '{"queued": false}'
else
background job_id = 'ask/answer', room: room, question: context.params.question
echo '{"queued": true}'
endif
%}
app/views/partials/channels/replies/subscribed.liquid admits a room only when its signature matches.
{% liquid
assign parts = context.params.room_id | split: '.'
assign expected = parts[0] | compute_hmac: context.constants.REPLY_ROOM_SECRET
if parts.size == 2 and parts[1] == expected
echo 'true'
else
echo 'false'
endif
%}
app/views/partials/ask/answer.liquid is the background job. It calls the service and always sends something back, so the visitor is never left waiting.
{% liquid
assign payload = { "question": question }
assign body = payload | json
assign headers = { "Content-Type": "application/json" }
assign headers['Authorization'] = 'Bearer ' | append: context.constants.ANSWER_SERVICE_KEY
graphql r = 'ask/call_service', body: body, headers: headers
assign reply = {}
if r.api_call_send.response.status == 200
assign reply['text'] = r.api_call_send.response.body
else
assign reply['error'] = 'No answer this time. Please try again.'
endif
graphql sent = 'ask/send_reply', room: room, reply: reply
%}
app/graphql/ask/call_service.graphql
mutation call_service($body: String!, $headers: HashObject) {
api_call_send(
api_call: { url: "https://api.example.com/answer", method: "POST", headers: $headers, body: $body }
options: { timeout: 120 }
) {
response { status body }
}
}
app/graphql/ask/send_reply.graphql
mutation send_reply($room: String!, $reply: JSONPayload!) {
channel_send_message(channel_name: "replies", room_id: $room, payload: $reply)
}
See it working first: the Live Features Demo runs Server-Sent Events, background job progress, a shared live feed, a file upload and the log query on this site.
A page that only listens can subscribe with an
const identifier = JSON.stringify({ channel: 'replies', room_id: room });
const source = new EventSource('/anycable-events?identifier=' + encodeURIComponent(identifier)
+ '&authenticity_token=' + encodeURIComponent(token));
source.onmessage = function (event) { show(JSON.parse(event.data)); };
Server-Sent Events are available on Insites instances from 15 October 2026. To check an instance, run
The example above only listens, so it can use Server-Sent Events. This version of its script opens an
<script>
const token = {{ context.authenticity_token | json }};
const identifier = JSON.stringify({ channel: 'replies', room_id: {{ room | json }} });
const form = document.getElementById('ask-form');
const reply = document.getElementById('reply');
const transport = document.getElementById('transport');
function ready(name) {
document.body.dataset.transport = name;
transport.textContent = 'Connected over ' + name;
form.querySelector('button').disabled = false;
}
function show(message) {
reply.textContent = message.text || message.error;
document.body.dataset.replied = 'true';
}
// Server-Sent Events first: read-only, which is all this page needs.
function connectEventSource() {
let confirmed = false;
const source = new EventSource('/anycable-events?identifier=' + encodeURIComponent(identifier)
+ '&authenticity_token=' + encodeURIComponent(token));
source.addEventListener('confirm_subscription', function () { confirmed = true; ready('Server-Sent Events'); });
source.onmessage = function (event) { show(JSON.parse(event.data)); };
source.onerror = function () {
source.close();
if (!confirmed) {
// Never subscribed: this instance does not serve SSE (or refused the room). Use the WebSocket.
document.body.dataset.sse = 'unavailable';
connectWebSocket();
} else {
// Dropped after it worked: reconnect with a fresh EventSource, never the browser's own retry.
setTimeout(connectEventSource, 2000);
}
};
}
function connectWebSocket() {
const socket = new WebSocket(
'wss://' + location.host + '/websocket?authenticity_token=' + encodeURIComponent(token),
['actioncable-v1-json']
);
socket.onmessage = function (event) {
const msg = JSON.parse(event.data);
if (msg.type === 'welcome') {
socket.send(JSON.stringify({ command: 'subscribe', identifier: identifier }));
} else if (msg.type === 'confirm_subscription') {
ready('WebSocket');
} else if (msg.identifier === identifier && msg.message) {
show(msg.message);
}
};
socket.onclose = function () { setTimeout(connectWebSocket, 2000); };
}
if (window.EventSource) { connectEventSource(); } else { connectWebSocket(); }
form.addEventListener('submit', function (event) {
event.preventDefault();
reply.textContent = 'Working on it...';
fetch('/ask-start', {
method: 'POST',
headers: { 'Accept': 'application/json' },
body: new URLSearchParams({ question: form.question.value, authenticity_token: token })
});
});
</script>
Didn't quite find what you are looking for or have feedback on how we can make the content better then we would love to hear from you. Please provide us feedback and we will get back to you shortly.