Skip to main content
connic
Back to BlogTutorial

Build a Streaming Chat with connic WebSocket Connectors

Connect a chat app to a connic agent over WebSocket: authenticate, stream replies, keep history across reconnects, and handle guardrails and approvals.

October 23, 202610 min readAuthor: connic engineering

A connic WebSocket connector connects a chat app to a deployed agent over one persistent connection. The browser opens that connection itself, each message starts an agent run, and the reply streams back as events that carry the message's ID. This tutorial builds the client and the agent configuration: per-user authentication, streamed replies and progress events, conversation history that survives a reconnect, and the cases where output guardrails, approvals, or errors change how a reply arrives.

Create the connector

The connector belongs to the agent that answers the chat, here support-chat. In the dashboard, open the agent, click Add inbound connector in the Connector Flow, choose Create New Connector, and select WebSocket. connic creates the URL and shows it in the connector's detail drawer. The connector can also be declared in code, so its settings deploy together with the agent:

connectors/support-chat.yaml
type: websocket
config:
  require_auth: false            # each message carries a user token instead
  progress_events: true          # model, tool, and approval events
  session_timeout: 1800          # close a connection after 30 idle minutes
  max_messages_per_session: 500
agents:
  - support-chat

The next deploy creates the connector. Streaming is on by default. require_auth: false turns off Require Authentication, so the browser can connect without the connector secret, and progress_events adds the status events shown below. session_timeout closes a connection after that many idle seconds (60 to 86,400, default 3,600), and max_messages_per_session caps the messages one connection can send (1 to 10,000, default 100). Both limits close the connection and leave the conversation's history in place. The connectors in code reference covers per-environment overrides.

Authenticate every message

Without Require Authentication, anyone who knows the URL can open a connection, so the agent checks who sent each message. The browser adds the signed-in user's token to every message, and the agent's middleware verifies it before the run continues. The token belongs in every message because each message is a separate run. The connector secret stays out of the page, where any visitor could read it, and each run knows the user it serves:

middleware/support-chat.py
from typing import Any

from connic import StopProcessing


def user_from_token(token: str | None) -> str | None:
    """Check the token with your sign-in provider and return the user ID."""
    raise NotImplementedError


async def before(content: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
    payload = context["payload"]
    user_id = user_from_token(payload.get("token"))
    if user_id is None:
        raise StopProcessing("Please sign in again.")
    context["user_id"] = user_id
    context["conversation_key"] = f"{user_id}:{payload['conversation_id']}"
    files = [part for part in content["parts"] if "text" not in part]
    content["parts"] = [{"text": payload["message"]}, *files]
    return content

user_from_token stands for the check that the app's sign-in provider offers. When it fails, StopProcessing completes the run with its message as the reply, so a user with an expired session reads "Please sign in again." in the chat. The middleware also runs when a run resumes after an approval or starts after waiting in a queue, with the token from the original message, so tokens should stay valid for longer than the approval timeout. It replaces the model's input with the message text, so the token and the conversation ID never reach the agent's model. The recorded run input, run context, and session state still contain the payload. New environments already redact fields named token in those records. Older environments can add *.token to their sensitive data redaction setting. A backend that calls the connector without a browser can keep Require Authentication on and send the connector secret instead. If Require Authentication is still on when a browser connects, connic waits for that secret and closes the connection after 30 seconds with an AUTH_TIMEOUT error.

Send messages and read the reply events

When the connection opens, connic sends a connected event that lists the linked agents and the connection limits. Each chat message is a message event with a payload object. The id is optional, but connic copies it into the acknowledgment, the stream events, and most errors about that message, and the client matches replies by it. Errors about the connection, and a few others such as MESSAGE_LIMIT, carry no ID. The payload holds the text, the token, and a conversation_id for the history set up in the next section:

Message from the client
{
  "type": "message",
  "id": "m-1",
  "payload": {
    "conversation_id": "conv-blender",
    "message": "My blender arrived with a cracked jar.",
    "token": "<session token>"
  }
}

connic acknowledges the message and streams the reply:

Events from connic
{ "type": "ack", "id": "m-1", "message_number": 1 }
{ "type": "stream_start", "id": "m-1", "agent": "support-chat" }
{ "type": "thinking", "id": "m-1", "agent": "support-chat" }
{ "type": "stream_chunk", "id": "m-1", "agent": "support-chat", "chunk": "Sorry to hear that. " }
{ "type": "stream_chunk", "id": "m-1", "agent": "support-chat", "chunk": "Would you like a replacement jar or a refund?" }
{
  "type": "stream_end",
  "id": "m-1",
  "agent": "support-chat",
  "full_response": "Sorry to hear that. Would you like a replacement jar or a refund?",
  "token_usage": { "input_tokens": 212, "output_tokens": 18, "total_tokens": 230 }
}

stream_start opens the reply, which is the moment to show a typing indicator, and thinking follows once the model starts. stream_chunk events carry text as the model writes it. stream_end closes the reply with full_response, the run's final output, and token_usage. full_response is the text to store. Middleware that rewrites the answer can make it differ from the joined chunks, so chunks work as a preview that stream_end replaces. With streaming turned off on the connector, a single response event takes the place of the stream events, and connic sends no progress events.

With streaming on, progress events report what the agent does before and between text chunks. thinking marks the start of each model call, tool_call and tool_result name a tool when the agent calls it and when it returns, and approval_pending and approval_resolved frame an approval pause. A reply that looks up an order:

Progress events from connic
{ "type": "thinking", "id": "m-5", "agent": "support-chat" }
{ "type": "tool_call", "id": "m-5", "agent": "support-chat", "tool": "lookup_order" }
{ "type": "tool_result", "id": "m-5", "agent": "support-chat", "tool": "lookup_order" }
{ "type": "thinking", "id": "m-5", "agent": "support-chat" }
{ "type": "stream_chunk", "id": "m-5", "agent": "support-chat", "chunk": "Your order shipped on Monday." }

The events carry only the tool's name, never its arguments or result, and the client picks the label, for example "Looking up your order" for lookup_order. Because the browser receives them directly, the tool names are visible in its developer tools.

A minimal browser client. getToken returns the signed-in user's current token from the app's sign-in library, so a refreshed token goes out with the next message:

chat-connection.js
const MAX_RETRY_DELAY_MS = 30_000;

export function createChatConnection({ url, getToken, onPreview, onReply, onFailure, onStatus }) {
  let socket;
  let ready = false;
  let stopped = false;
  let attempt = 0;
  const inFlight = new Map(); // message id -> preview text

  function connect() {
    if (stopped) return;
    socket = new WebSocket(url);
    socket.onmessage = (event) => handle(JSON.parse(event.data));
    socket.onclose = () => {
      ready = false;
      for (const id of inFlight.keys()) onFailure(id, "interrupted");
      inFlight.clear();
      if (stopped) return;
      setTimeout(connect, Math.min(1000 * 2 ** attempt++, MAX_RETRY_DELAY_MS));
    };
  }

  function handle(event) {
    switch (event.type) {
      case "connected":
        ready = true;
        attempt = 0;
        break;
      case "stream_chunk": {
        const preview = (inFlight.get(event.id) ?? "") + event.chunk;
        inFlight.set(event.id, preview);
        onPreview(event.id, preview);
        break;
      }
      case "stream_end":
        inFlight.delete(event.id);
        if (event.error) onFailure(event.id, event.error);
        else onReply(event.id, event.full_response);
        break;
      case "thinking":
      case "tool_call":
      case "tool_result":
      case "approval_pending":
      case "approval_resolved":
        onStatus(event.id, event); // the UI turns event.type and event.tool into a label
        break;
      case "error":
        if (!event.id) break; // connection errors carry no id; onclose reports open messages
        inFlight.delete(event.id);
        onFailure(event.id, event.code);
        break;
      // ack, stream_start and ping need no handling here
    }
  }

  connect();

  return {
    close() {
      stopped = true;
      socket.close();
    },
    async send(conversationId, text) {
      const token = await getToken();
      if (!ready) return null; // the UI keeps the text and shows "reconnecting"
      const id = crypto.randomUUID();
      inFlight.set(id, "");
      socket.send(JSON.stringify({
        type: "message",
        id,
        payload: { conversation_id: conversationId, message: text, token },
      }));
      return id;
    },
  };
}

Each browser tab opens its own connection. Within one connection, messages are processed one at a time, in the order received, and a message sent while a reply is running waits for it. The UI calls close() when the chat closes; otherwise the client reconnects after every close, including the idle timeout. Runs from all connections share the agent's max_concurrent_runs, which defaults to 1, so with the default one user's reply waits for another's. The agent file in the next section raises it to 10, which needs the developer plan or higher. The subscription plan caps the value, and a deploy that exceeds the cap fails. If several agents are linked to the connector, each one answers in turn with its own stream_start to stream_end sequence and its name in agent. The client above assumes one agent.

Keep the conversation across reconnects

Every message starts a separate agent run. The agent sees earlier messages only through a persistent session, and session.key decides which messages share one. Here it reads conversation_key, which the middleware builds from the verified user ID and the conversation_id:

agents/support-chat.yaml
version: "1.0"

name: support-chat
type: llm
model: connic/gpt-5.6-terra
description: "Answers customer questions in the in-app chat"
system_prompt: |
  You are the support assistant in the Acme app. Answer in a few sentences.

max_concurrent_runs: 10

session:
  key: context.conversation_key
  ttl: 604800   # history expires after 7 days without a message

concurrency:
  key: conversation_id
  on_conflict: queue

Because the key contains the user ID, a user who sends someone else's conversation_id gets a separate, empty session rather than that conversation's history. One connection can carry several conversations, and a new connection continues a conversation as soon as it sends the same conversation_id with a valid token. A run whose key is missing fails. Here, one user has two conversations open on the same connection when it drops. Each reply shows its full_response:

conv-blender
  1. Customer: My blender arrived with a cracked jar.

  2. Agent: Sorry to hear that. Would you like a replacement jar or a refund?

  3. Connection dropped, client reconnected
  4. Customer: The replacement, please.

  5. Agent: Okay, a replacement jar for your blender. What is your order number?

conv-shipping
  1. Customer: Do you ship to Austria?

  2. Agent: Yes, we ship to Austria.

  3. Connection dropped, client reconnected
  4. Customer: What did I just ask you?

  5. Agent: You asked whether we ship to Austria.

After the reconnect, the agent still knows that the blender conversation is about the cracked jar, and the shipping conversation contains only its own messages. Long conversations can outgrow the model's context window. With context compression enabled on the agent, connic shortens the history when that happens.

Connection limits and history expiry

The connector settings limit the connection. The agent's ttl limits the conversation:

SettingSet onWhen it is reached
session_timeoutConnectorconnic closes the idle connection. The history stays.
max_messages_per_sessionConnectorconnic sends a MESSAGE_LIMIT error and closes the connection. The history stays.
session.ttlAgentThe conversation's history expires, and the next message starts without it.

After either of the first two, the client reconnects and continues the same conversation. Without ttl, history never expires.

Reconnects and interrupted replies

connic does not send earlier messages to a new connection. The chat UI keeps its own transcript for display, while the session holds the history the model reads. A reply that is still running when the connection drops does not reach the new connection either. Its run finishes anyway, and the answer becomes part of the history. The client above reports such replies as interrupted; the UI can show that and let the user ask again.

The concurrency block in the agent file covers this case. Runs from different connections are not serialized, and when two runs in one session overlap, the run that writes second fails. With the concurrency rule on conversation_id, a message sent right after a reconnect, or from a second browser tab, waits until the earlier run in that conversation has finished.

Handle guardrails, approvals, and failures

Every reply uses the same event sequence. Guardrails, approvals, and failures change when its text arrives and whether it ends with full_response or an error.

Output guardrails send the reply in one piece

Output guardrails check the complete reply before the client sees any of it. connic therefore does not stream partial text for an agent with output rules. This rule blocks replies that contain card numbers, US social security numbers, or API keys, and sends the customer a fixed sentence instead:

agents/support-chat.yaml (excerpt)
guardrails:
  output:
    - type: pii_leakage
      mode: block
      config:
        rejection_message: "I can't share that here. Please contact support by email."

The same question, with and without the rule:

Without output guardrails
  1. stream_start
  2. thinking
  3. stream_chunkSorry to hear
  4. stream_chunkthat. Would you like
  5. stream_chunka replacement jar or a refund?
  6. stream_end
With the pii_leakage rule
  1. stream_start
  2. thinking
  3. No text while the agent writes and the guardrail checks the reply
  4. stream_chunkSorry to hear that. Would you like a replacement jar or a refund?
  5. stream_end

The customer waits longer for the first text, but the format does not change. A client that makes no assumption about the number of chunks handles both cases without changes. Progress events still arrive during the wait, because they contain no reply text. When the rule blocks, the rejection_message replaces the reply and the run completes, so the client shows it like any other answer. With fail_run: true in the rule's config, the run fails instead and stream_end carries an error. Input guardrails run before the agent and do not change streaming.

Approvals pause the reply

A tool that needs approval stops the run until a team member approves or rejects the call. The connection stays busy with that reply, and later messages on the connection queue behind it. approval_pending names the tool when the run pauses, which gives the UI its waiting state, and approval_resolved reports the decision (approved, rejected, or timeout) once it is made. Without progress events, nothing arrives during the wait. The waiting time does not count toward the run timeout, and approval.timeout defaults to 3,600 seconds, a long time for a customer looking at a chat window. A short timeout (the minimum is 30 seconds) and on_rejection: continue keep the conversation moving:

agents/support-chat.yaml (excerpt)
tools:
  - orders.lookup_order
  - orders.cancel_order

approval:
  tools:
    - orders.cancel_order
  timeout: 120
  on_rejection: continue
  message: "A customer asked the chat assistant to cancel an order."

With on_rejection: continue, a rejection or an expired timeout returns a rejection message to the model as the tool result, and the agent tells the customer what happened. With the default, fail, the run fails and the reply ends with an error. After the decision, the rest of the reply streams, and full_response contains the text from before and after the pause. Human-in-the-loop approvals covers conditional gates and notification channels.

Failures end the reply with an error

Every message ends with stream_end (response with streaming off) or with an error event, unless the connection drops first. A failed, cancelled, or timed-out run produces stream_end with an error field instead of full_response. That text is written for the team, so the UI shows its own message to the customer and the team finds the details in the run's trace. A rejected token is not a failure: its StopProcessing message arrives as a normal reply. error events report the other common problems with a code:

CodeCauseClient reaction
PARSE_ERROR, EMPTY_MESSAGE, UNKNOWN_TYPEInvalid JSON, an empty payload, or an unknown event typeFix the client; the connection stays open
DISPATCH_ERRORconnic could not start the runOffer the user a retry
MESSAGE_LIMITThe connection reached max_messages_per_session and closesReconnect and resend the rejected message
CONNECTOR_INACTIVEThe connector was paused; connic rejects the next message and closes the connectionKeep retrying with backoff; new connections succeed once it is resumed

On an idle connection, connic also sends ping events, which the client can ignore. The WebSocket connector reference covers setup, settings, and the message format.

Put a deployed agent behind your chat

Deploy an agent on connic, add a WebSocket connector, and stream replies into your product with history that survives reconnects.

Frequently Asked Questions

Not by itself. Each message starts its own agent run, and history comes from the agent's persistent session. session.key has to identify the conversation, for example with a key that middleware builds from the verified user ID and a conversation_id sent with every message. A new connection that sends the same conversation_id then continues the conversation.

Yes. With Require Authentication turned off on the connector, every message carries the signed-in user's token, and the agent's middleware verifies it before the run continues. The connector secret never has to be in browser code.

Yes. With progress_events and streaming enabled on the WebSocket connector, connic sends thinking when a model call starts, tool_call and tool_result with the tool's name, and approval_pending and approval_resolved around an approval. The events never carry arguments or results, and the client decides how to label them.

Agents with output guardrails send the reply as one stream_chunk after the run, because the guardrails check the complete text before any of it reaches the client. The event sequence stays the same. When streaming is turned off on the connector, a single response event arrives instead of the stream events.

Yes. A files array in the message payload carries base64-encoded files for multimodal models: up to 10 files, with 10 MiB per file and 10 MiB combined. From the first file that exceeds a limit, the remaining files are omitted and listed in file_issues.

More from the Blog

Tutorial

Track AI Token Costs by Customer or Use Case in connic

Middleware tags each connic agent run with a customer or use case, so dashboards and the Token Usage page can rank token cost by that value.

October 15, 20267 min read
Tutorial

Expose the Invoice Processor Template as an MCP Tool

Deploy connic's invoice template as an MCP tool, discover it from Python, send invoice text, and check the extracted fields with a complete client example.

September 11, 202610 min read
Tutorial

How to Deploy a Python AI Agent Without Kubernetes

A Python AI agent deployed without Kubernetes using YAML, plain Python, deployment-gated tests, Git, and a managed EU runtime. Includes working code.

August 12, 202612 min read
Tutorial

How to Trigger AI Agents from Kafka Topics

Point a connic Kafka inbound connector at a topic and every message starts an agent run. Configure the connector, link an agent, deploy, and watch runs.

July 12, 20268 min read
Tutorial

How Small Engineering Teams Can Add an AI Agent to SaaS

A practical, step-by-step path to shipping a first production AI agent with a small team: scope one job, define it in config, connect it to existing systems, and let a runtime handle the rest.

June 12, 20269 min read
Tutorial

Automated Agent Scoring: AI Agent Evaluation with LLM Judges

Automated agent scoring uses an LLM judge to grade sampled or every matching agent run against defined criteria. Track score trends and alert on regressions.

March 29, 202610 min read
Tutorial

Migrate from LangChain to Production AI Agents

A working LangChain prototype still needs to handle real traffic. Migrate existing agent code to a production-grade platform without rewriting from scratch.

March 23, 202611 min read
Tutorial

AI Agents: From Prototype to Production

A demo works great until 1,000 concurrent users arrive. A practical guide to the production requirements most teams find out about too late.

January 10, 202610 min read
Tutorial

Hidden Costs of Self-Hosting AI Agents

We'll just deploy it on Kubernetes. Famous last words. The true cost of self-hosting AI agents versus a managed platform.

December 18, 20257 min read