<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Huy Nguyen</title>
    <description>The latest articles on DEV Community by Huy Nguyen (@huynguyengl99).</description>
    <link>https://dev.to/huynguyengl99</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1654565%2F9f1c9a82-ef1d-447c-9b5c-f8ebaf086c6e.jpeg</url>
      <title>DEV Community: Huy Nguyen</title>
      <link>https://dev.to/huynguyengl99</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/huynguyengl99"/>
    <language>en</language>
    <item>
      <title>Type-safe, scalable WebSockets in Python: a FastAPI tutorial</title>
      <dc:creator>Huy Nguyen</dc:creator>
      <pubDate>Sat, 31 Oct 2026 06:51:02 +0000</pubDate>
      <link>https://dev.to/huynguyengl99/fastapi-gives-http-a-docs-page-heres-how-to-get-one-for-websockets-plus-a-typed-typescript-ifh</link>
      <guid>https://dev.to/huynguyengl99/fastapi-gives-http-a-docs-page-heres-how-to-get-one-for-websockets-plus-a-typed-typescript-ifh</guid>
      <description>&lt;p&gt;This is the error this post builds up to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/chat.ts(8,60): error TS2339: Property 'text' does not exist on type 'ChatNotificationPayload'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Python server renamed a WebSocket field from &lt;code&gt;text&lt;/code&gt; to &lt;code&gt;body&lt;/code&gt;, and the TypeScript frontend found out at compile time, through a client generated from the server's code. Without that client the field reads as &lt;code&gt;undefined&lt;/code&gt;, nothing throws, and the user sees an empty chat bubble. The same kind of bug (&lt;code&gt;content&lt;/code&gt; on one side, &lt;code&gt;body&lt;/code&gt; on the other) once cost me most of a day, and it's how I opened &lt;a href="https://youtu.be/-LPCnm3F9Mc?t=7250" rel="noopener noreferrer"&gt;my talk at AsyncAPI Conf 2026&lt;/a&gt;, which covers the approach behind this post.&lt;/p&gt;

&lt;p&gt;FastAPI already gives HTTP routes this kind of safety: type hints become an OpenAPI schema, a &lt;code&gt;/docs&lt;/code&gt; page and generated clients. &lt;code&gt;@app.websocket&lt;/code&gt; gets none of it. You receive a dict, check a string and send a dict back, and nothing describes those messages.&lt;/p&gt;

&lt;p&gt;So in this post we build a small FastAPI room chat where every WebSocket message is a Pydantic model. From those models we get validation, an AsyncAPI docs page, and typed TypeScript and Python clients. Then we move it onto Redis so it keeps working when you run more than one server, and push messages into it from a plain HTTP endpoint. It takes about 15 minutes, and every command and output below comes from a real run (chanx 2.13.0, FastAPI 0.162, @chanx-js/client 2.3, Redis 7).&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;room-chat &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;room-chat
python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"chanx[fast_channels]"&lt;/span&gt; uvicorn websockets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The library doing the work is &lt;a href="https://github.com/chanx-dev/chanx" rel="noopener noreferrer"&gt;chanx&lt;/a&gt;, which I maintain. It also runs on Django Channels and other ASGI frameworks, but this post sticks to FastAPI.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Messages are Pydantic models
&lt;/h2&gt;

&lt;p&gt;Each message has a constant &lt;code&gt;action&lt;/code&gt; field and a typed payload. Create &lt;code&gt;main.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fast_channels.layers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;InMemoryChannelLayer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;register_channel_layer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chanx.core.decorators&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ws_handler&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chanx.fast_channels&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncapi_docs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asyncapi_spec_json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chanx.fast_channels.websocket&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncJsonWebsocketConsumer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chanx.messages.base&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseMessage&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chanx.messages.incoming&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PingMessage&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chanx.messages.outgoing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PongMessage&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseMessage&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;A message a user posts to the room.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ChatPayload&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatNotificationPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseMessage&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;A message broadcast to everyone in the room.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat_notification&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat_notification&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ChatNotificationPayload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the &lt;code&gt;Literal&lt;/code&gt; on &lt;code&gt;action&lt;/code&gt;. It becomes &lt;code&gt;"const"&lt;/code&gt; in the JSON schema, which lets the code generator later turn "some object" into a union the TypeScript compiler can narrow on. The docstrings travel too, into the docs page and as comments in the generated clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. One handler per message
&lt;/h2&gt;

&lt;p&gt;Add the consumer below the models:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;room_chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Chat in a named room&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RoomChatConsumer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AsyncJsonWebsocketConsumer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;channel_layer_alias&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post_authentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;room&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path_params&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;room_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join_group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;room_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@ws_handler&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_ping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PingMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PongMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PongMessage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="nd"&gt;@ws_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ChatNotification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;broadcast_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;ChatNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ChatNotificationPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For every incoming message, chanx picks the handler whose argument type has a matching &lt;code&gt;action&lt;/code&gt;, so you never write a &lt;code&gt;receive&lt;/code&gt; method or an &lt;code&gt;if action == ...&lt;/code&gt; chain.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;handle_ping&lt;/code&gt; returns its reply, which goes back to the sender only. &lt;code&gt;handle_chat&lt;/code&gt; broadcasts to the room's group instead, and because its return type is &lt;code&gt;None&lt;/code&gt;, &lt;code&gt;output_type&lt;/code&gt; tells chanx (and the docs) what it sends. &lt;code&gt;join_group&lt;/code&gt; adds the connection to a group named after the room in the URL, and the connection leaves that group on its own when it disconnects.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Wire it into FastAPI
&lt;/h2&gt;

&lt;p&gt;At the bottom of &lt;code&gt;main.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;register_channel_layer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;InMemoryChannelLayer&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;# Redis in production
&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_websocket_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/ws/room/{room_name}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RoomChatConsumer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;as_asgi&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;


&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/asyncapi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;asyncapi_docs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/asyncapi.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;asyncapi_spec_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The channel layer is what carries broadcasts between connections. In-memory is fine while everything runs in one process; section 9 swaps it for Redis.&lt;/p&gt;

&lt;p&gt;Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uvicorn main:app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Try it
&lt;/h2&gt;

&lt;p&gt;Here's a quick script with two users in the same room. Save it as &lt;code&gt;try_it.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;websockets&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ws://localhost:8000/ws/room/lobby&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;websockets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;websockets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;bob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ping&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}))&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice &amp;lt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hi bob&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}}))&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice &amp;lt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bob   &amp;lt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;bob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}}))&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice &amp;lt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;


&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python try_it.py
alice &amp;lt;- {"action": "pong", "payload": null}
alice &amp;lt;- {"action": "chat_notification", "payload": {"room": "lobby", "text": "hi bob"}}
bob   &amp;lt;- {"action": "chat_notification", "payload": {"room": "lobby", "text": "hi bob"}}
alice &amp;lt;- {"action": "error", "payload": [{"type": "missing", "loc": ["chat", "payload", "text"], "msg": "Field required"}]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the last line: &lt;code&gt;txt&lt;/code&gt; instead of &lt;code&gt;text&lt;/code&gt; never reached &lt;code&gt;handle_chat&lt;/code&gt;. Pydantic rejected it first, and the sender got an error naming the missing field.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The docs page
&lt;/h2&gt;

&lt;p&gt;Open &lt;a href="http://localhost:8000/asyncapi" rel="noopener noreferrer"&gt;http://localhost:8000/asyncapi&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fejwd6ss7recku2266y50.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fejwd6ss7recku2266y50.png" alt="The generated AsyncAPI docs page" width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the official AsyncAPI viewer reading &lt;code&gt;/asyncapi.json&lt;/code&gt;, which chanx builds from the handlers: the channel and its &lt;code&gt;room_name&lt;/code&gt; parameter, each operation, and each message's schema and docstring. Since it comes from the code on every request, it can't drift out of date the way a wiki page does.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A typed TypeScript client
&lt;/h2&gt;

&lt;p&gt;The same spec feeds a client generator. In a frontend project (or an empty folder):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @chanx-js/client
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; @chanx-js/codegen typescript
npx chanx-codegen http://localhost:8000/asyncapi.json &lt;span class="nt"&gt;-o&lt;/span&gt; src/generated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generator reads the spec and writes plain TypeScript. Part of &lt;code&gt;src/generated/schemas.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** A message a user posts to the room. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ChatMessage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ChatPayload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/** A message broadcast to everyone in the room. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ChatNotification&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chat_notification&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ChatNotificationPayload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;src/generated/channels.ts&lt;/code&gt; knows which messages go in which direction, and which reply each request gets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RoomChatToServer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ChatMessage&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;PingMessage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RoomChatToClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ChatNotification&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;PongMessage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RoomChatReplies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ChatNotification&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;ping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PongMessage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="cm"&gt;/** Chat in a named room */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;roomChat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;defineChannel&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RoomChatToServer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RoomChatToClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RoomChatReplies&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;room_chat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/ws/room/{room_name}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;heartbeat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using it (&lt;code&gt;src/chat.ts&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@chanx-js/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;roomChat&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./generated/index.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ws://localhost:8000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomChat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;room_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lobby&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chat_notification&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pong&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ping&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;server replied with&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pong&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hello from TypeScript&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ npx tsx src/chat.ts
server replied with pong
[lobby] hello from TypeScript
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;message&lt;/code&gt; in the &lt;code&gt;on&lt;/code&gt; callback is already a &lt;code&gt;ChatNotification&lt;/code&gt;, and &lt;code&gt;request({ action: 'ping' })&lt;/code&gt; resolves to a &lt;code&gt;PongMessage&lt;/code&gt;, both inferred from the spec. In React, Vue, Svelte or Solid you'd use the matching hook from &lt;code&gt;@chanx-js/client/react&lt;/code&gt; (and so on) instead of &lt;code&gt;createClient&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Mistakes become compile errors
&lt;/h2&gt;

&lt;p&gt;Now some common mistakes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomChat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;room&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lobby&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chatt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;txt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chat_notification&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;room&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lobby&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ npx tsc --noEmit
error TS2353: Object literal may only specify known properties, and 'room' does not exist in type '{ room_name: string | number; }'.
error TS2820: Type '"chatt"' is not assignable to type '"chat" | "ping"'. Did you mean '"chat"'?
error TS2561: Object literal may only specify known properties, but 'txt' does not exist in type 'ChatPayload'. Did you mean to write 'text'?
error TS2322: Type '"chat_notification"' is not assignable to type '"chat" | "ping"'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last one is easy to miss: &lt;code&gt;chat_notification&lt;/code&gt; is a message the server sends, and the spec says so, so the client isn't allowed to send it.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The rename
&lt;/h2&gt;

&lt;p&gt;Back to the error from the top of the post. On the server, rename &lt;code&gt;text&lt;/code&gt; to &lt;code&gt;body&lt;/code&gt; in &lt;code&gt;ChatNotificationPayload&lt;/code&gt; (and in &lt;code&gt;handle_chat&lt;/code&gt;), restart, and regenerate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx chanx-codegen http://localhost:8000/asyncapi.json &lt;span class="nt"&gt;-o&lt;/span&gt; src/generated
npx tsc &lt;span class="nt"&gt;--noEmit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/chat.ts(8,60): error TS2339: Property 'text' does not exist on type 'ChatNotificationPayload'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The build fails on the line that reads the old field. Without the generated client, that same line would compile and quietly render nothing.&lt;/p&gt;

&lt;p&gt;In my projects I commit the generated folder and run the generator in CI against the backend's spec. A backend PR that renames a field then shows the client diff in the same PR, and the frontend type check fails before anything merges.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Scale it out with Redis
&lt;/h2&gt;

&lt;p&gt;So far everything runs in one process. In production you'd run several servers behind a load balancer, and two people in the same room can easily land on different ones. Before testing that, one more feature real apps need.&lt;/p&gt;

&lt;p&gt;Messages don't only come from other WebSocket clients. A notification usually starts in an HTTP view or a background job, and for that chanx sends events through the channel layer. Add one line to the consumer so it forwards &lt;code&gt;ChatNotification&lt;/code&gt; events straight to its client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RoomChatConsumer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AsyncJsonWebsocketConsumer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;channel_layer_alias&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;passthrough_events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ChatNotification&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and an ordinary FastAPI endpoint that broadcasts one into a room:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Announcement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/rooms/{room_name}/announce&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;announce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;room_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;announcement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Announcement&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;RoomChatConsumer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;broadcast_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;ChatNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ChatNotificationPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;room_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;announcement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;room_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;room_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The event is the same Pydantic model the clients already know, so it's typed end to end, and the spec picks it up as a &lt;code&gt;send&lt;/code&gt; operation.&lt;/p&gt;

&lt;p&gt;Now start two copies of the app, still on the in-memory layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;httpx
uvicorn main:app &lt;span class="nt"&gt;--port&lt;/span&gt; 8001   &lt;span class="c"&gt;# server A&lt;/span&gt;
uvicorn main:app &lt;span class="nt"&gt;--port&lt;/span&gt; 8002   &lt;span class="c"&gt;# server B&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connect Alice to A and Bob to B, both in &lt;code&gt;lobby&lt;/code&gt;, then post an announcement to B (save as &lt;code&gt;two_servers.py&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;websockets&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(nothing)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;alice_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ws://localhost:8001/ws/room/lobby&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# server A
&lt;/span&gt;    &lt;span class="n"&gt;bob_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ws://localhost:8002/ws/room/lobby&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# server B
&lt;/span&gt;    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;websockets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alice_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;websockets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bob_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;bob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hi bob&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}}))&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice &amp;lt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bob   &amp;lt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bob&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8002/rooms/lobby/announce&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deploy at 5pm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice &amp;lt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bob   &amp;lt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bob&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;


&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python two_servers.py
alice &amp;lt;- {"action": "chat_notification", "payload": {"room": "lobby", "text": "hi bob"}}
bob   &amp;lt;- (nothing)
alice &amp;lt;- (nothing)
bob   &amp;lt;- {"action": "chat_notification", "payload": {"room": "lobby", "text": "deploy at 5pm"}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each server only knows about its own connections, so Bob never sees Alice's message and Alice never sees the announcement. The fix is a channel layer every server shares. Start Redis and install the Redis extra:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 6379:6379 redis:7-alpine
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"fast-channels[redis]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then change the layer setup in &lt;code&gt;main.py&lt;/code&gt;. The consumer, the handlers and the endpoint stay as they are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fast_channels.layers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;register_channel_layer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fast_channels.layers.redis&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RedisChannelLayer&lt;/span&gt;

&lt;span class="n"&gt;redis_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REDIS_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis://localhost:6379&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;register_channel_layer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RedisChannelLayer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hosts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;redis_url&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart both servers and run the script again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python two_servers.py
alice &amp;lt;- {"action": "chat_notification", "payload": {"room": "lobby", "text": "hi bob"}}
bob   &amp;lt;- {"action": "chat_notification", "payload": {"room": "lobby", "text": "hi bob"}}
alice &amp;lt;- {"action": "chat_notification", "payload": {"room": "lobby", "text": "deploy at 5pm"}}
bob   &amp;lt;- {"action": "chat_notification", "payload": {"room": "lobby", "text": "deploy at 5pm"}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both servers now deliver both messages, including the announcement that was posted to server B only. A third or tenth server works the same way. The same &lt;code&gt;broadcast_event&lt;/code&gt; call also works from an async background worker, and sync code such as a Celery task can use &lt;code&gt;broadcast_event_sync&lt;/code&gt;. If you regenerate the TypeScript client from section 6, it still compiles.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Bonus: a Python client for another service
&lt;/h2&gt;

&lt;p&gt;The same spec works for a Python service talking to this one (in my case it's usually an AI agent service talking to the backend):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"chanx[cli,client]"&lt;/span&gt;
chanx generate-client &lt;span class="nt"&gt;--schema&lt;/span&gt; http://localhost:8000/asyncapi.json &lt;span class="nt"&gt;--output&lt;/span&gt; ./chat_client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chat_client.room_chat&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RoomChatClient&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chat_client.room_chat.messages&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChatNotification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChatPayload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PongMessage&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Bot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RoomChatClient&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_init_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ChatPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hello from a Python service&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ChatNotification&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;PongMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;ChatNotification&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;PongMessage&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="k"&gt;pass&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Bot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ws://localhost:8000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path_params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;room_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lobby&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python bot.py
[lobby] hello from a Python service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated messages are Pydantic models, so mypy and pyright check this side too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go from here
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The same consumer runs on Django Channels: import it from &lt;code&gt;chanx.channels.websocket&lt;/code&gt; instead and route it with Channels. &lt;a href="https://python.chanx.dev/en/latest/quick-start.html?framework=django" rel="noopener noreferrer"&gt;Django quick start&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Auth, testing helpers, events pushed from HTTP views or background jobs: &lt;a href="https://python.chanx.dev" rel="noopener noreferrer"&gt;chanx docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;TypeScript client, hooks for each framework, and a Node server that speaks the same protocol: &lt;a href="https://js.chanx.dev" rel="noopener noreferrer"&gt;chanx-js&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;How Python handlers map to AsyncAPI 3 channels, operations and replies, in detail: &lt;a href="https://youtu.be/-LPCnm3F9Mc?t=7250" rel="noopener noreferrer"&gt;my AsyncAPI Conf 2026 talk&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/chanx-dev/chanx" rel="noopener noreferrer"&gt;github.com/chanx-dev/chanx&lt;/a&gt; (BSD).&lt;/p&gt;

&lt;p&gt;Hope it saves you the day it cost me. If you keep WebSocket messages in sync some other way, share your tips in the comments; I'm always happy to pick up a better trick.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>websocket</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Agent Graph Engineering, Part 3: Contracts, Not Conventions</title>
      <dc:creator>Huy Nguyen</dc:creator>
      <pubDate>Sat, 31 Oct 2026 02:30:56 +0000</pubDate>
      <link>https://dev.to/huynguyengl99/agent-graph-engineering-part-3-contracts-not-conventions-26p</link>
      <guid>https://dev.to/huynguyengl99/agent-graph-engineering-part-3-contracts-not-conventions-26p</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; Three services in two languages means three seams, and a hand-maintained seam is a slow leak. Declare the shapes once on the server, generate everything else: OpenAPI becomes a typed REST client, AsyncAPI becomes a typed WebSocket client in TypeScript and another in Python. The compiler then tells you what broke instead of a user telling you. The same declarations build the approval form, so a reviewer cannot approve a call whose fields they were never shown.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://dev.to/huynguyengl99/agent-graph-engineering-part-2-split-the-services-and-know-where-the-line-goes-nka"&gt;Part 2&lt;/a&gt; split the agent off from the business and drew the line at state ownership. That split creates a boundary, and a boundary between two codebases is where systems quietly go wrong. This post is about making it impossible for that boundary to drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;h2&gt;
  
  
  The failure mode, which is not a crash
&lt;/h2&gt;

&lt;p&gt;I have debugged a WebSocket message shape that disagreed between a backend and a frontend, and it cost me most of a day. Not because it is hard to find. Because both sides are &lt;em&gt;right&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The backend sends &lt;code&gt;{ticket_id, content}&lt;/code&gt;. The frontend reads &lt;code&gt;{ticketId, body}&lt;/code&gt;. Each codebase is internally consistent, each has tests, and each team is certain. Nothing throws. The field is simply &lt;code&gt;undefined&lt;/code&gt;, which in JavaScript renders as nothing at all, so the symptom is an empty space in the UI where a message should be. You go looking for a bug in rendering, then in the socket, then in the serializer, and the actual defect is that two people wrote down the same idea twice and one of them was six weeks ago.&lt;/p&gt;

&lt;p&gt;The REST world mostly solved this a decade ago and we stopped noticing. You write a serializer, a spec falls out, the frontend generates a client, and a renamed field becomes a compile error. Nobody considers that remarkable any more.&lt;/p&gt;

&lt;p&gt;The agent layer has no such habit. WebSocket message shapes are still usually a hand-written union on one side and a hand-written type on the other, with a code review as the only thing holding them together. That is a convention, not a contract. Conventions survive exactly as long as the people who remember them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Types are the stance, generation is the machinery
&lt;/h2&gt;

&lt;p&gt;Part 1 claimed that the structure of the system should be a declared artifact rather than something you reconstruct by reading code. Contracts are where that claim stops being philosophy.&lt;/p&gt;

&lt;p&gt;The order matters, and it is worth being explicit because it is easy to get backwards. &lt;strong&gt;Typing is a design stance you adopt first.&lt;/strong&gt; Declare a message as a model. Declare a tool's arguments as a schema. Declare an agent's output as a type. None of that needs a generator, and all of it pays off immediately in one codebase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generation is the machinery you add once a second codebase exists.&lt;/strong&gt; Before that it is ceremony. The moment two processes have to agree, it stops being optional, because you have introduced a place where two people's understanding can differ silently.&lt;/p&gt;

&lt;p&gt;So: declare on the server, generate everywhere else, and never write the same shape twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three seams, three generators
&lt;/h2&gt;

&lt;p&gt;The demo application has three services and two languages, which produces three places a shape can cross a boundary.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Seam&lt;/th&gt;
&lt;th&gt;Source of truth&lt;/th&gt;
&lt;th&gt;Spec&lt;/th&gt;
&lt;th&gt;Generated artifact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Browser to backend, REST&lt;/td&gt;
&lt;td&gt;DRF serializers&lt;/td&gt;
&lt;td&gt;OpenAPI&lt;/td&gt;
&lt;td&gt;Zodios client with Zod schemas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser to backend, WebSocket&lt;/td&gt;
&lt;td&gt;chanx consumers and topics&lt;/td&gt;
&lt;td&gt;AsyncAPI&lt;/td&gt;
&lt;td&gt;TypeScript client via &lt;code&gt;chanx-codegen&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend to agent, WebSocket&lt;/td&gt;
&lt;td&gt;the agent's chanx topics&lt;/td&gt;
&lt;td&gt;AsyncAPI&lt;/td&gt;
&lt;td&gt;a Python client the backend imports&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Drawn out, with the tool that does each step on the arrow:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;graph LR
    A[DRF serializers] --&amp;gt;|drf-spectacular| B[OpenAPI]
    B --&amp;gt;|openapi-zod-client| C[Zodios client]
    B --&amp;gt;|script| D[TypeScript types]

    E[Backend consumers] --&amp;gt;|chanx| F[AsyncAPI]
    F --&amp;gt;|chanx-js codegen| G[Typed WS client]

    H[Agent consumers] --&amp;gt;|chanx generate-client| I[Python WS client]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Every arrow is a generator, and nothing on the right of one is hand-written. One command runs all of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gen:all = gen:schema &amp;amp;&amp;amp; gen:type &amp;amp;&amp;amp; gen:ws
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is deliberately boring. The interesting part is what comes out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What generation actually preserves
&lt;/h2&gt;

&lt;p&gt;Here is the Python definition of the team's half of a ticket feed. It is the whole thing, and it is mostly a list of what may travel on that channel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicketTeamTopic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Topic&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;TeamFeedEvent&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Outbound only: the actions a reviewer takes are sent on the ticket&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s own
    topic, which they are also subscribed to.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticket:{ticket_id}:team&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;passthrough_events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;NewEventMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;AnswerStreamingMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;AgentProgressMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ApprovalRequiredMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ToolProposalMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ReasoningStreamingMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here is what the frontend generator produces from it, after the AsyncAPI document in between:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;HubTicketTeamTopicToServer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;never&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;HubTicketTeamTopicToClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;AgentProgressMessage&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;AnswerStreamingMessage&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;ApprovalRequiredMessage&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;NewEventMessage&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;ReasoningStreamingMessage&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;ToolProposalMessage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cm"&gt;/** Outbound only: the actions a reviewer takes are sent on the ticket's own
 * topic, which they are also subscribed to. */&lt;/span&gt;
&lt;span class="nl"&gt;ticketTeamTopic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;defineTopic&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;
  &lt;span class="nx"&gt;HubTicketTeamTopicToServer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;HubTicketTeamTopicToClient&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ticket_team_topic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ticket:{ticket_id}:team&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ignore the spelling. The same name appears three ways across those two blocks, and that is a case transform you configure once and forget, not anything the contract is doing. What matters is which fields crossed.&lt;/p&gt;

&lt;p&gt;The list did, exactly. Six message types on the Python side, the same six in the TypeScript union, and each one carries its own generated type behind it. Add a seventh to &lt;code&gt;passthrough_events&lt;/code&gt; and it appears in the union on the next generation; remove one and every frontend branch still handling it stops compiling. That is the property worth having, and it is about fields rather than names.&lt;/p&gt;

&lt;p&gt;Two more details are worth stopping on.&lt;/p&gt;

&lt;p&gt;The first is &lt;code&gt;ToServer = never&lt;/code&gt;. The Python class says outbound only, by listing no inbound handlers, and that fact survives all the way into the type system of a different language. A frontend developer who tries to send on that topic gets a compile error rather than a message that vanishes into a channel nobody reads. Nobody wrote that rule down twice. It was declared once, as the absence of handlers, and the absence travelled.&lt;/p&gt;

&lt;p&gt;The second is the docstring. The sentence explaining &lt;em&gt;why&lt;/em&gt; the topic is outbound only is now a JSDoc comment hovering in the editor of whoever is about to use it. I did not expect to care about that and now would not give it up. The reasoning lives next to the code that depends on it, in both languages, without anyone maintaining a second copy of the explanation.&lt;/p&gt;

&lt;p&gt;This is also why I keep saying your WebSocket layer deserves what your REST layer already has. It is the same trick. There is no technical reason the channel your agent streams on should be less well specified than the endpoint that lists your tickets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The schema can also build the interface
&lt;/h2&gt;

&lt;p&gt;Here is where it stops being about typing and starts being about safety.&lt;/p&gt;

&lt;p&gt;The agent can propose a tool call that a human has to approve before it runs. A refund, for instance. The reviewer needs to see the arguments, check them, possibly correct them, and then approve. That is a form.&lt;/p&gt;

&lt;p&gt;The naive version is a hand-written form per tool. It works on the day you write it, and then the tool grows a field. The comment on the component that replaced all of them says it better than I will paraphrase it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hand-written forms are what rot: a field is added server-side, the form is
not updated, and nobody finds out until a request fails or - worse, for the
tool gate - until a reviewer approves a call whose arguments they were never
shown.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that last clause again, because it is the whole argument. A stale form is not a cosmetic bug in an approval flow. It is a human being asked to authorize something while part of it is hidden from them. The approval is still recorded, the tool still runs with the field the reviewer never saw, and the audit trail says a person checked it.&lt;/p&gt;

&lt;p&gt;So the form is generated too. One engine reads a Zod schema and renders fields, validation, and defaults from it. It has two sources feeding it, which is the part I like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;REST forms get their schema from the OpenAPI generator.&lt;/li&gt;
&lt;li&gt;A tool's arguments arrive over the WebSocket as JSON Schema, at runtime, and are converted to Zod on the way in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way the form, the validation, and the defaults are read off the same object, so a form cannot offer a field the server will reject, and it cannot hide one the server will accept. A tool nobody has built yet already has a working approval UI.&lt;/p&gt;

&lt;p&gt;Here is the result, for a refund the agent proposed and a reviewer has not yet approved:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc49syhcq0jsu2l2zvxj4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc49syhcq0jsu2l2zvxj4.png" alt="The issue_refund approval gate, its fields and help text rendered from the tool's argument schema" width="800" height="643"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nobody wrote that form. "The customer being refunded", "Amount in GBP" and "Why the refund is owed, for the audit trail" are the field descriptions off the tool's schema, rendered straight through. Add a field to the tool and it appears here, described, validated, and in front of the person approving it.&lt;/p&gt;

&lt;p&gt;That property is only available because the tool's arguments were a declared schema in the first place. It is the compound interest on a decision made two posts ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;A generated contract is not free, and the costs are the kind that annoy people on a Tuesday.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is a build step, and build steps get forgotten.&lt;/strong&gt; Change a serializer, forget to regenerate, and you get a type error that points at the generated file rather than at what you actually did. You learn to read it quickly, but the first few are confusing. A pre-commit hook or a CI check is the real answer, and the repo validates the schema separately with &lt;code&gt;--fail-on-warn&lt;/code&gt; so an ambiguous spec fails before it reaches a generator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generation needs the server running.&lt;/strong&gt; The frontend generator fetches the live schema endpoint, so &lt;code&gt;just gen&lt;/code&gt; starts whatever service it needs first. That is convenient and it is also a dependency, and it means you cannot regenerate on a plane.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generated code is ugly and you must not edit it.&lt;/strong&gt; Both generated files open with a line telling you not to. Someone will anyway, once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You inherit the generator's opinions.&lt;/strong&gt; Field naming, optionality, and how a discriminated union maps into the target language are all decisions the tool makes for you. Mostly fine, occasionally not what you would have picked, and essentially never worth fighting.&lt;/p&gt;

&lt;p&gt;None of those compare to the afternoon I described at the top.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you do not need any of this
&lt;/h2&gt;

&lt;p&gt;One service and one language: skip it. If your types are already shared because everything imports from the same module, a generator adds a step and buys nothing. The whole value is in crossing a process or a language boundary.&lt;/p&gt;

&lt;p&gt;A prototype you intend to throw away: also skip it. The cost here is front-loaded and the payoff accrues over months.&lt;/p&gt;

&lt;p&gt;A public API with external consumers you cannot recompile: generation still helps you, but it stops being a safety net, because the compiler that catches drift is yours and theirs is not. Versioning becomes the actual problem and that is a different post by a different person.&lt;/p&gt;

&lt;p&gt;The sweet spot is exactly the shape in this series: a handful of services, one team, two languages, changing weekly. That is where a generated seam turns a class of silent production bug into a red squiggle.&lt;/p&gt;

&lt;p&gt;Next up is &lt;strong&gt;"the agent and the graph together"&lt;/strong&gt;, which is where the series finally gets into the part everyone came for: a typed model call, and the flow that decides what happens next. Hope this one is useful, particularly the form argument, which I think is underrated.&lt;/p&gt;

&lt;p&gt;I write these deep-dives and maintain a handful of Python libraries, so if that is your kind of thing, follow along on &lt;a href="https://github.com/huynguyengl99" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; and &lt;a href="https://www.linkedin.com/in/huynguyengl99/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>langgraph</category>
      <category>agents</category>
      <category>python</category>
    </item>
    <item>
      <title>Agent Graph Engineering, Part 2: Split the Services, and Know Where the Line Goes</title>
      <dc:creator>Huy Nguyen</dc:creator>
      <pubDate>Fri, 23 Oct 2026 04:34:44 +0000</pubDate>
      <link>https://dev.to/huynguyengl99/agent-graph-engineering-part-2-split-the-services-and-know-where-the-line-goes-nka</link>
      <guid>https://dev.to/huynguyengl99/agent-graph-engineering-part-2-split-the-services-and-know-where-the-line-goes-nka</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; Put your product's domain in one service and your agent graph in another, and draw the line at state ownership rather than at anything that sounds like a layer. The agent owns execution state, which is a LangGraph checkpoint nobody can rebuild by hand. The backend owns the record, which is what a page reload needs. Updates travel one way, agent to backend, over a generated WebSocket client. The payoff is not tidiness, it is that a node is a function you can call with a state you invented, while the same decision inside a socket consumer needs a connection and a user before it will run at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://dev.to/posts/agent-graph-engineering/the-stack-and-why-each-piece-is-there/"&gt;Part 1&lt;/a&gt; listed the stack and said, in one line, to keep business logic out of the agent. That line does a lot of work and deserves its own post, because it is the decision that is nearly free this month and expensive in a year, and because "split it into two services" is useless advice until somebody tells you where the cut goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the merged version looks like
&lt;/h2&gt;

&lt;p&gt;I have shipped the single-service version. It does not fail dramatically. It degrades, in a way that is hard to argue with in a code review because every individual step was reasonable.&lt;/p&gt;

&lt;p&gt;It goes something like this. Your agent needs to know whether a customer is on the paid plan, so the prompt grows a line about entitlements. Then billing adds an annual tier, and now the prompt is wrong in a way no test catches, because the prompt is a string and nothing validates strings against your billing model. Meanwhile a graph node needs the ticket's history, so it reads the ORM directly, and now your graph cannot run in a test without a database and fixtures. Six months later somebody asks whether the agent can be deployed separately, because the model calls are slow and the web workers are blocked waiting on them, and the answer is no, not without unpicking all of it.&lt;/p&gt;

&lt;p&gt;The two failure directions are worth naming separately, because they feel different while you are in them. Business rules leak into prompts, where they cannot be type-checked or unit-tested. Graph concerns leak into models, where a &lt;code&gt;tickets&lt;/code&gt; table starts carrying columns about node execution. Neither side can be tested alone by then, which is the point at which the split stops being a refactor and becomes a rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test you cannot write
&lt;/h2&gt;

&lt;p&gt;That last sentence is the one that matters, so here is what it looks like from inside.&lt;/p&gt;

&lt;p&gt;In the merged version the agent's decision happens inside the socket consumer, in a method that has already authenticated the user, loaded the conversation and written a row. To assert that a particular message routes to a particular capability, you have to arrive at that line the way a user does: open a connection, authenticate it, seed a session and a conversation, and let the whole thing run until the part you care about executes. The assertion is one line. Everything above it is travel.&lt;/p&gt;

&lt;p&gt;The mocks are worse than the setup. Because the model is reached through that object rather than across a boundary, there is no single place to stub it. You stub each collaborator where it happens to be used, so the fakes multiply with the call graph instead of with the number of seams. A suite like that gains patches faster than it gains assertions, and past a certain density nobody can tell you which parts are still being exercised and which are being asserted against a mock of themselves.&lt;/p&gt;

&lt;p&gt;A graph node does not have that problem, and not because graphs are virtuous. It is because a node is a function from state to state. You build the state you want, call it, and look at what came back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_knowledge_base_branch_grounds_the_answer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;thread&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kb-branch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;mock_openai&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CLASSIFY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SEARCH_KB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ANSWER_FROM_KB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nc"&gt;SupportGraph&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;checkpointer&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ainvoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;context&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;()},&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call_count&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;SearchKnowledgeBase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# The retrieval step ran and fed the answer agent.
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kb_snippets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kb-002&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;snippet&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;snippet&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kb_snippets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole test, not an excerpt. The three capitalized names are canned model replies declared at the top of the file. There is no socket to open and nobody to log in, because the node never needed either. The only thing faked is the model's HTTP response, which is the one real boundary in the picture, and everything underneath it runs: three model calls, a retrieval step, a routing decision, and an answer that had to cite what retrieval handed back.&lt;/p&gt;

&lt;p&gt;So the argument for splitting is narrower and more useful than "separation of concerns". It was never about tidiness. It was about being able to call the thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cut is about state, not layers
&lt;/h2&gt;

&lt;p&gt;The instinct is to split by technology or by layer: an API tier, a service tier, maybe a cache in front. That produces a diagram your architect likes and a boundary that leaks within a month, because layers are not what is actually different about these two things.&lt;/p&gt;

&lt;p&gt;What is actually different is the kind of state each one holds.&lt;/p&gt;

&lt;p&gt;The agent owns execution state: which node runs next, the current channel values, the payload of a pending interrupt. In LangGraph that is a checkpoint, and it lives in Postgres. The important property is that you cannot rebuild it by hand. If you lose a checkpoint, you have not lost a row you could recompute from other rows. You have lost the ability to resume, because reconstructing it means reimplementing LangGraph's execution model, and you are not going to do that. Resumption is the whole mechanism behind human approval, so this state is load-bearing in a way ordinary application state is not.&lt;/p&gt;

&lt;p&gt;The backend owns the record: messages, ticket events, and the two things a page reload needs to find, which are a drafted reply waiting for approval and a proposed tool call. None of those are copies of graph state. They exist because a browser cannot ask the agent what is currently waiting, and because a stored approval row is what lets the approval card come back after a refresh.&lt;/p&gt;

&lt;p&gt;Once the line is drawn there, a lot of smaller questions answer themselves. Permissions, billing, and user records are obviously the backend's, because they are the record. Prompts, tools, and routing are obviously the agent's, because they are execution. The service the agent exposes has one job, and the service your product lives in never imports LangGraph.&lt;/p&gt;

&lt;p&gt;In the reference implementation it comes out as three services:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Owns&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;backend/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;users, tickets, the thread as a person reads it&lt;/td&gt;
&lt;td&gt;8000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agent/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;graphs, tools, checkpoints, the model's memory&lt;/td&gt;
&lt;td&gt;8001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;web/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing; it renders what the other two generate&lt;/td&gt;
&lt;td&gt;5173&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three services and four processes, because a run is driven by a worker rather than by whichever web process accepted the request. That worker is the backend's own code pulling from a queue, so it owns nothing the table does not already list.&lt;/p&gt;

&lt;p&gt;The agent is internal. Only the backend connects to it, never a browser, and that matters more than it looks. The approval gate lives inside the graph, so anything that can open a socket on the agent can both propose a tool call and approve it. Network isolation is the primary control there and a shared token is the second one, which is why the token is optional in a fresh clone and not optional anywhere real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two threads, not one
&lt;/h2&gt;

&lt;p&gt;I got this wrong the first time, and the symptom was confusing enough to be worth the detail.&lt;/p&gt;

&lt;p&gt;There are two conversation histories in a system like this, and they are not the same object.&lt;/p&gt;

&lt;p&gt;One is the thread as a person reads it: an ordered list of events you can render, paginate, and show to a customer. That is rows, and it belongs to the backend.&lt;/p&gt;

&lt;p&gt;The other is the thread as the model remembers it, which in Pydantic AI is its own message history. That is not a list of rows. It carries tool calls and their results, the shapes of previous structured outputs, and the sequencing the model needs to behave consistently on the next turn. Flattening it into "a list of messages" throws away exactly the parts that make the next call work.&lt;/p&gt;

&lt;p&gt;I first gave that model memory its own table, filled by a background job once the message itself had been saved. Two copies of one thread, written by two processes at two different times, which is the shape of a problem whether or not you have hit it yet. The gap between those writes is not a rare crash window either, which is what I had told myself. It is ordinary operation: the job queues, it retries, occasionally it does not arrive, and the write that already succeeded neither knows nor cares. When the two disagreed, neither had been derived from the other, so neither could be rebuilt. There was no correct recovery, only a choice about which copy to believe.&lt;/p&gt;

&lt;p&gt;The fix was to stop treating it as a second source of truth. The model's memory now rides on the graph's state, so the checkpointer that already persists a parked run persists the memory too. One write, one thing to lose. And when a lane has nothing remembered yet, it starts from the record the backend sends, which quietly turns the memory into a cache over the record rather than a peer of it. Lose a checkpoint now and what you lose is the shape of past tool calls, not the conversation.&lt;/p&gt;

&lt;p&gt;That is the test I would apply to any state you are tempted to put on the agent side. If losing it means losing data, it belongs in the record. If losing it only means losing speed or fidelity, the agent can keep it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updates travel one way
&lt;/h2&gt;

&lt;p&gt;Neither service mirrors the other, and only one direction carries updates.&lt;/p&gt;

&lt;p&gt;A graph node broadcasts an event on its topic. The backend is subscribed, and its relay does two things with each event: writes down whatever belongs in the record, and fans the event out to the browser group. So the backend learns what happened by being told. It never reads the agent's tables, and the agent never writes to the backend's.&lt;/p&gt;

&lt;p&gt;The useful consequence is that the record is derived, which means it can lag but it cannot diverge. A terminal event persists the turn. A parked run persists the approval card. A tool proposal is deliberately not persisted as a turn, because it only becomes one if it actually runs.&lt;/p&gt;

&lt;p&gt;The place this shows up as real code is a small protocol on the backend describing everything the agent can announce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Sink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Where a run&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s events are written down. Every hook is optional.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classified&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;why&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decided&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;why&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;drafted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;approval_required&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reply_sent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reply_blocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;answered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tool_proposed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tool_ran&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reasoning_delta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reasoned&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That interface is the boundary, written down. Three different callers open connections to the agent in this app: a ticket's customer-facing run, the same ticket's internal team run, and a standalone conversation. They turned out to differ only in what they wrote down, never in how they talked to the agent, so the talking lives in one place and the writing-down is whichever &lt;code&gt;Sink&lt;/code&gt; you hand it. Every hook being optional is the bit that makes that work: a caller that does not care about token deltas simply does not implement &lt;code&gt;token&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A boundary you can read in twenty lines is also a boundary you can test. Pass a recording &lt;code&gt;Sink&lt;/code&gt;, drive the relay, and assert on what it would have written, with no database and no model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary is generated, not hand-written
&lt;/h2&gt;

&lt;p&gt;Two services talking means a contract, and a hand-maintained contract between two codebases is a slow leak. I have watched a WebSocket message shape drift between a backend and a frontend that each believed they were right, and there is no satisfying way to debug it, because both sides are internally consistent.&lt;/p&gt;

&lt;p&gt;So nothing here is hand-written. The agent's consumers produce an AsyncAPI document, and a generated Python client is what the backend imports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;helpdesk.agent_client.agent_hub_support_topic.client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;AgentHubSupportTopicClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;helpdesk.agent_client.agent_hub_support_topic.messages&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ApprovalRequiredMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ReplySentMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RunRequestMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ToolApprovalMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change a message on the agent, regenerate, and the backend stops type-checking. That is the entire mechanism, and it is the same trick your REST API has had through OpenAPI for a decade. The next post covers how the generation works and what drift actually costs. For now the only claim is that the seam between two services is the last place you want a shape that exists only in two people's heads.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Two services is more than one service, and I would rather say the costs out loud than discover them in your comments.&lt;/p&gt;

&lt;p&gt;You now run two services locally, each with its own environment file and a shared token that has to match in both, plus a worker and the web app. The repo hides that behind a single setup command, but somebody wrote that command.&lt;/p&gt;

&lt;p&gt;You have a network hop on the critical path. For this workload it disappears into model latency, which is measured in seconds, so a few milliseconds of local socket is noise. If your agent were doing something fast and chatty, the calculus would change.&lt;/p&gt;

&lt;p&gt;You have a contract to regenerate. It is one command, but it is a step, and forgetting it produces a confusing failure until you learn the shape of it.&lt;/p&gt;

&lt;p&gt;And you have two deploys, two sets of logs, and two things that can be down independently. Tracing across the boundary stops being free, which is a large part of why observability gets a post of its own rather than a paragraph.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to do this
&lt;/h2&gt;

&lt;p&gt;I do not think this is unconditional. If you are building a prototype to find out whether an idea works, one service is correct, and splitting early is the kind of discipline that feels responsible while slowing you down for no return. The same goes for a tool with one user, which is you.&lt;/p&gt;

&lt;p&gt;The signals that it is time: the model calls are slow enough that they are blocking your web workers; you want to scale the agent independently, or on different hardware; more than one person is working on the system and the prompts and the business rules are colliding in review; or you have started writing graph concepts into your application tables.&lt;/p&gt;

&lt;p&gt;The reason to do it before you are forced to is simply that the cost curve is steep. Drawing the line while there is nothing across it is an afternoon. Drawing it after a year of leaks is the rewrite that gets postponed every quarter, and that one never gets cheaper.&lt;/p&gt;

&lt;p&gt;Next up is types and contracts, which is the other idea the rest of the series leans on and the thing that makes this boundary enforceable rather than aspirational. Hope this one is useful if you are staring at a single service and wondering whether to cut it.&lt;/p&gt;

&lt;p&gt;I write these deep-dives and maintain a handful of Python libraries, so if that is your kind of thing, follow along on &lt;a href="https://github.com/huynguyengl99" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; and &lt;a href="https://www.linkedin.com/in/huynguyengl99/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>langgraph</category>
      <category>architecture</category>
      <category>python</category>
    </item>
    <item>
      <title>Agent Graph Engineering, Part 1: The Stack, and Why Each Piece Is There</title>
      <dc:creator>Huy Nguyen</dc:creator>
      <pubDate>Fri, 23 Oct 2026 04:26:37 +0000</pubDate>
      <link>https://dev.to/huynguyengl99/agent-graph-engineering-part-1-the-stack-and-why-each-piece-is-there-3h61</link>
      <guid>https://dev.to/huynguyengl99/agent-graph-engineering-part-1-the-stack-and-why-each-piece-is-there-3h61</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; The stack is Python, Pydantic AI for agents, LangGraph for the flow, &lt;a href="https://github.com/huynguyengl99/chanx" rel="noopener noreferrer"&gt;chanx&lt;/a&gt; for typed streaming WebSockets and a generated frontend contract, OpenTelemetry into a trace store you own, pytest with a mocked LLM for fast tests, and real-model evals for regressions. Business logic stays in a separate service from the agent graph. The single idea holding it together: every boundary in the system is a declared, typed artifact rather than something you hold in your head. This post makes the case for each piece so you can bring it to your team, then lays out what we build in the rest of the series.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://dev.to/posts/agent-graph-engineering/before-it-had-a-name/"&gt;Part 0&lt;/a&gt; was the story: years of shipping LLM features as intent detection plus nested conditionals, and why observability tooling never fixed the underlying problem. This post is the pragmatic half.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One clarification first, because the word is overloaded. This series is about the &lt;em&gt;execution&lt;/em&gt; graph of an agent: state, nodes, edges, routing, interrupts, resumption. It is not graph RAG, and not knowledge graphs. Those are retrieval techniques, where a graph is the data you query. Graph RAG could sit behind one node here as one tool among several. Same word, different concept, and that collision is a large part of why there is so little material on this subject.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The one idea
&lt;/h2&gt;

&lt;p&gt;Everything below follows from a single principle: &lt;strong&gt;the structure of your AI system should be a declared artifact, not something reconstructed by reading code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your flow should be a graph you can render. Your tool inputs and outputs should be schemas. Your WebSocket messages should be a contract your frontend can generate a client from. Your agent's output should have a type.&lt;/p&gt;

&lt;p&gt;The reason is not elegance. It is that declared structure can be rendered, validated, traced, and diffed automatically, while structure that only exists as control flow can only be understood by a human reading it. That difference is what decides whether your system survives its third feature and its second engineer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worth your time
&lt;/h2&gt;

&lt;p&gt;The case for each piece is below. Before that, the reasons you might care beyond whatever you are shipping this week, because I think there are three different readers here.&lt;/p&gt;

&lt;p&gt;If your team already runs an agent in production, you do not need convincing that it is hard. The useful part is the set of practices that remove whole categories of bug rather than fixing them one at a time. Typed tool arguments turn a wrong type into a validation error at the edge instead of a &lt;code&gt;TypeError&lt;/code&gt; three frames into your own code. Structured output means you route on &lt;code&gt;isinstance&lt;/code&gt; rather than on whether the response happened to contain the word "escalate". Generated contracts mean a message shape cannot drift between two codebases without the compiler noticing. Checkpointing means a crashed run resumes instead of vanishing. Guardrails at both edges mean the text you screened is the text that ships. Evals mean a model upgrade is a measurement rather than a gamble. None of those are features you gain. They are bugs you stop having, which is a different and better thing.&lt;/p&gt;

&lt;p&gt;If you are about to build your first one, the useful part is the order. These decisions are sequenced the way they actually bite, and the one that is cheap this month and expensive next year is the service split, which is why it turns up early rather than in a footnote. Reading ahead costs an afternoon and might save you a rewrite.&lt;/p&gt;

&lt;p&gt;If you are building for yourself or for a client, the repository is the point. It is distilled from a production system of five services, out of roughly five years on realtime infrastructure and agent systems, so the parts that look over-careful are mostly the parts that broke somewhere else first. It is production-shaped rather than notebook-shaped, and it runs on one API key, or none at all. Moving it to another domain is mostly swapping tools, prompts, and edges. I want to be careful with the word production, though. The shape is right and the last mile is real, so the final post is a plain list of what you still owe before paying users touch it. Nobody is helped by pretending a checkout is a deployment.&lt;/p&gt;

&lt;p&gt;Then there is the market, if you are weighing where to put your learning time. Stanford's AI Index 2026 found that mentions of the "agentic AI" skill cluster in job postings grew more than 280% in a year, from 0.06% of postings in 2024 to 0.23% in 2025, which is roughly 90,000 US listings. Fast growth from a small base, and both halves of that matter: it is early, and it is moving. The detail I find more telling sits in the same report. Mentions of "ChatGPT", "conversational AI" and "chatbots" went down over the same period. Demand moved off demos. Korn Ferry's 2026 survey of 1,674 talent leaders has 52% planning to deploy autonomous agents by the end of this year, which is the same shift seen from the other side of the table.&lt;/p&gt;

&lt;p&gt;The job ads are the more useful signal, though, and they say something slightly different from the headline. Reading through current senior backend openings, the ones that touch AI are not asking for prompt engineering. They ask for distributed systems, availability and latency targets, production Python, schema and API design, architecture review across teams, and operational practice: service levels, incident response, containers and orchestration. AI turns up as a capability expected &lt;em&gt;inside&lt;/em&gt; all that, rather than instead of it. That is a more demanding ask than the hype implies, and it maps almost exactly onto what this series builds, which is a system with contracts, traces, tests and a deployment story that happens to have a model in it.&lt;/p&gt;

&lt;p&gt;The sentence that keeps turning up in hiring write-ups is that a demo agent is easy and a reliable one is not, and that eval literacy is the clearest sign somebody has actually shipped. That is more or less this entire series, arrived at independently and the slow way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;What it buys you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;The only ecosystem where every provider, every serving runtime, and every eval tool shows up first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent&lt;/td&gt;
&lt;td&gt;Pydantic AI&lt;/td&gt;
&lt;td&gt;Typed tools, typed outputs, typed deps, provider independence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flow&lt;/td&gt;
&lt;td&gt;LangGraph&lt;/td&gt;
&lt;td&gt;Declared graph, renderable diagram, inspectable state, checkpointing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transport&lt;/td&gt;
&lt;td&gt;chanx&lt;/td&gt;
&lt;td&gt;Typed WebSocket messages, broadcast from anywhere, generated AsyncAPI contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tracing&lt;/td&gt;
&lt;td&gt;OpenTelemetry&lt;/td&gt;
&lt;td&gt;One connected trace per user message, readable with no account and exportable to any OTLP backend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests&lt;/td&gt;
&lt;td&gt;pytest + mocked LLM&lt;/td&gt;
&lt;td&gt;Fast, deterministic, no API spend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regressions&lt;/td&gt;
&lt;td&gt;Evals with real models&lt;/td&gt;
&lt;td&gt;Catch what mocks cannot when you change a model or a prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business logic&lt;/td&gt;
&lt;td&gt;A separate service&lt;/td&gt;
&lt;td&gt;Keeps your product's domain out of your agent's graph&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs&lt;/td&gt;
&lt;td&gt;A worker, on Celery&lt;/td&gt;
&lt;td&gt;A run outlives the process that asked for it, so a deploy does not take the work in flight with it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Python
&lt;/h3&gt;

&lt;p&gt;Not a controversial pick, but worth stating the actual reason. It is not that Python has the best SDK for calling an API, because every language has that now. It is everything around the call: local model serving, embeddings, evaluation harnesses, vector stores, document parsing. When you eventually need to run a model yourself or plug in a tool nobody has wrapped yet, Python is where it already exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pydantic AI, for the agent
&lt;/h3&gt;

&lt;p&gt;An agent is a thing with a declared output type and a declared set of tools. Pydantic AI is built on exactly that, and being Pydantic-native means your tool schemas and output schemas are the same objects you already validate with.&lt;/p&gt;

&lt;p&gt;What that gets you in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Structured output.&lt;/strong&gt; The agent returns a typed object, not a string you regex. Routing on &lt;code&gt;isinstance&lt;/code&gt; beats routing on substring matching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validated tool calls.&lt;/strong&gt; The model's arguments are checked before your code runs. A wrong type is a caught validation error, not a &lt;code&gt;TypeError&lt;/code&gt; three frames deep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider independence.&lt;/strong&gt; Switching providers is a config change. This matters more than it sounds like it does, because you will want a cheap model for routing decisions and a strong one for synthesis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typed dependencies.&lt;/strong&gt; Your tools receive a typed context object instead of reading globals.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A word on determinism, because it gets oversold. You cannot make the model deterministic, and anyone promising that is selling something. What you can do is make everything around it deterministic: the model's only job is to return a value of a declared type, and every other step on the path is ordinary code you can test. That shift is most of what separates a system from a prompt.&lt;/p&gt;

&lt;p&gt;You could use a provider's own SDK or an ADK instead. I would not, because the moment you want a second provider or a self-hosted model, the abstraction you skipped has to be written anyway, and it will be worse.&lt;/p&gt;

&lt;p&gt;If you want to see this layer on its own before the graph arrives, I wrote it up separately in &lt;a href="https://dev.to/posts/pydantic-ai-typed-websockets-fastapi/"&gt;Streaming Agents with Pydantic AI over Typed WebSockets&lt;/a&gt;. That post is a good prerequisite for this series.&lt;/p&gt;

&lt;h3&gt;
  
  
  LangGraph, for the flow
&lt;/h3&gt;

&lt;p&gt;This is the piece that changes how you work, and the piece your team will push back on. So be specific about the trade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost is real.&lt;/strong&gt; You stop writing handler functions and start declaring state, nodes, and conditional edges. A branch becomes an edge. A handler becomes a subgraph. It takes a few weeks to stop fighting it, and the first graph you write will be wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get back:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A diagram from the code.&lt;/strong&gt; LangGraph exports Mermaid. Your architecture picture is generated from the thing that actually runs, so it cannot go stale. No more hand-drawn draw.io files that lie.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inspectable state at every step.&lt;/strong&gt; You can see what the state was going into a node and coming out. When something breaks, you know which transition did it rather than guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checkpointing.&lt;/strong&gt; Graph state persists. A run can be paused, resumed, and recovered after a crash, which is also the mechanism that makes human-in-the-loop approvals work rather than being a bolt-on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structure that survives change.&lt;/strong&gt; Adding a capability means adding a node and an edge, not threading another condition through an existing chain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest downsides: it is a LangChain-ecosystem library, so some of the ergonomics assume LangChain; and streaming from non-LangChain code goes through a generic custom channel, which is the gap chanx fills.&lt;/p&gt;

&lt;h3&gt;
  
  
  chanx, for transport and contract
&lt;/h3&gt;

&lt;p&gt;Disclosure: I wrote &lt;a href="https://github.com/huynguyengl99/chanx" rel="noopener noreferrer"&gt;chanx&lt;/a&gt;, because I kept rebuilding the same thing. The &lt;a href="https://dev.to/posts/chanx-structured-websockets-django-fastapi/"&gt;intro post&lt;/a&gt; has the full background.&lt;/p&gt;

&lt;p&gt;Three things it does for an agent system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Broadcast from anywhere.&lt;/strong&gt; A node buried in a subgraph can emit a progress event to the right user without the graph passing a stream handle down through every call. You can stream the model's reasoning while it works, instead of going silent and then dumping everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typed messages instead of a &lt;code&gt;receive_json&lt;/code&gt; if/else chain.&lt;/strong&gt; Every message is a model, routing is automatic, and bad payloads fail at the edge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generated AsyncAPI docs.&lt;/strong&gt; This is the one nobody else does. Your WebSocket contract becomes a spec, and your frontend generates a TypeScript client from it. Change the graph, regenerate, and the compiler tells the frontend what broke. Your REST API has had this via OpenAPI for a decade; there is no reason your agent layer should not.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It works with Django, FastAPI, or Litestar, which matters because we use two of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenTelemetry, into a trace store you own
&lt;/h3&gt;

&lt;p&gt;The usual advice here is to name a vendor. I would rather name the standard, because the vendor is the part you should be able to change.&lt;/p&gt;

&lt;p&gt;Both LangGraph and Pydantic AI instrument themselves with OpenTelemetry, so once a graph node opens a span, every model call inside it is already a child. You get one connected trace covering graph transitions and model calls together, rather than the flat list of completions a dashboard usually shows you.&lt;/p&gt;

&lt;p&gt;What this stack does with those spans is the part I would argue for. They go into a small trace store inside the agent service, which means &lt;code&gt;/traces&lt;/code&gt; renders a run as the chain of steps it was with &lt;strong&gt;no account, no signup, and no configuration&lt;/strong&gt;. That matters more than it sounds. A reference implementation that needs a vendor account before it shows you anything has lost most of its readers at step one, and an on-call engineer at 2am should not need a third party to answer "what did this run do".&lt;/p&gt;

&lt;p&gt;Exporting onward is then one environment variable: off, files on disk, an OTLP collector, or both. Any OTLP-speaking backend should work, since that is the point of a standard, and the repo covers that path with a fake collector that checks spans really leave the process with the right endpoint and auth header. Honest limit: that proves the export path, not any particular vendor's ingestion. Langfuse, Jaeger and Grafana are all OTLP endpoints plus a header, but I have verified the path rather than each product.&lt;/p&gt;

&lt;p&gt;When a user reports that the agent did something strange in production, you want evidence, not a reproduction attempt. This is the piece that gives you evidence, and it is yours.&lt;/p&gt;

&lt;h3&gt;
  
  
  pytest with a mocked LLM
&lt;/h3&gt;

&lt;p&gt;Mock at the HTTP layer, not at the framework layer. If you stub Pydantic AI's client you test your stubs. If you intercept the HTTP call and return a recorded provider response, the real pipeline runs: SSE parsing, tool call assembly, streaming, validation. Your tests then cover the parts that actually break.&lt;/p&gt;

&lt;p&gt;These tests are fast, deterministic, and free, so you can run them on every commit and cover full WebSocket flows end to end.&lt;/p&gt;

&lt;h3&gt;
  
  
  Evals with real models
&lt;/h3&gt;

&lt;p&gt;Mocked tests prove your code works. They cannot tell you whether the model still picks the right tool after you reworded a prompt, or whether the new model release routes the same way.&lt;/p&gt;

&lt;p&gt;That is what evals are for: scenarios run against real providers, on demand and never in CI, because they cost money. Pydantic AI has an eval module, or a plain script works. Without this you cannot upgrade a model without fear, which in practice means you stop upgrading.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep business logic out of the agent
&lt;/h3&gt;

&lt;p&gt;Put your product's domain (users, permissions, billing, persistence) in one service and your agent graph in another. I have shipped the merged version and it becomes a mess: business rules leak into prompts, graph concerns leak into models, and neither side can be tested alone.&lt;/p&gt;

&lt;p&gt;Separated, the agent service has one job, and the boundary between them is a contract you can generate a client from.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we build
&lt;/h2&gt;

&lt;p&gt;A support ticket triage assistant. A ticket arrives, and the agent decides what to do with it: answer it directly, search the knowledge base, escalate it to a human, or draft a reply. Posting that reply to the customer requires human approval; searching the knowledge base does not.&lt;/p&gt;

&lt;p&gt;Two views of one ticket. The customer sees it being worked, step by step:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fas3wic9xw0e587zlmbee.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fas3wic9xw0e587zlmbee.png" alt="The customer portal: their billing question, the agent's filing and search decisions, and a working indicator" width="800" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The team sees the same ticket with their own lane on it, internal notes beside the agent's answers and the step it chose:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftnx09ris5lk2rwhi5v1m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftnx09ris5lk2rwhi5v1m.png" alt="The team's lane: an internal note, the agent's tool steps, and the reply it wrote for staff" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I picked this deliberately. It needs real routing, so the graph earns its place instead of being a two-node demo. It has a naturally irreversible action, so the approval machinery is solving an actual problem rather than an invented one. And it runs on an LLM key alone, with no OAuth flows or third-party signups standing between you and a working checkout.&lt;/p&gt;

&lt;p&gt;The shape:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    FE["Frontend&amp;lt;br/&amp;gt;React"]
    BE["Backend&amp;lt;br/&amp;gt;Django"]
    AG["Agent&amp;lt;br/&amp;gt;FastAPI"]

    FE --&amp;gt;|"REST + WS&amp;lt;br/&amp;gt;OpenAPI"| BE
    BE --&amp;gt;|"typed WS&amp;lt;br/&amp;gt;AsyncAPI"| AG

    BE --- BEOWNS[["users, tickets,&amp;lt;br/&amp;gt;the thread a person reads"]]
    AG --- AGOWNS[["graphs, tools, checkpoints,&amp;lt;br/&amp;gt;the thread a model remembers"]]

    classDef owns fill:none,stroke-dasharray:4 3;
    class BEOWNS,AGOWNS owns;&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Three services, one repository. The frontend generates its clients from OpenAPI and AsyncAPI. The backend owns users, tickets, and history. The agent service owns the graphs, the tools, and the checkpoints, and talks to nobody's database but its own.&lt;/p&gt;

&lt;p&gt;It is small enough to read in an afternoon and complete enough to adopt: generated contracts on both boundaries, real WebSocket worker processes, approval interrupts on irreversible tools, guardrails on the input and output edges, streamed progress, a span per graph node with per-run cost, three test suites that spend nothing, evals against real providers, and container images serving ASGI. The architecture is production-grade and the deployment is production-shaped. What it does not do yet, context budgeting and spend caps among others, is written down in the repo rather than glossed over, and the last post walks that gap. Every post is pinned to a tag so you can check out the exact state being described.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;I do not want to undersell the ramp. Declaring flows as graphs is a genuine shift in how you think, and the first few weeks are slower than writing the conditionals would have been. Expect to throw away your first graph design once you understand state properly.&lt;/p&gt;

&lt;p&gt;What you get on the other side is an AI system you can point at, trace, test, and change. The agent stops being magic and starts being software.&lt;/p&gt;

&lt;p&gt;Next up is the service split, then types and contracts, and then the posts get into code. Hope this one is useful in the meantime, particularly if you are the person who has to justify the stack to everyone else.&lt;/p&gt;

&lt;p&gt;I write these deep-dives and maintain a handful of Python libraries, so if that is your kind of thing, follow along on &lt;a href="https://github.com/huynguyengl99" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; and &lt;a href="https://www.linkedin.com/in/huynguyengl99/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>langgraph</category>
      <category>websocket</category>
      <category>python</category>
      <category>ai</category>
    </item>
    <item>
      <title>Agent Graph Engineering, Part 0: I Needed This Before It Had a Name</title>
      <dc:creator>Huy Nguyen</dc:creator>
      <pubDate>Sat, 10 Oct 2026 02:01:12 +0000</pubDate>
      <link>https://dev.to/huynguyengl99/agent-graph-engineering-part-0-i-needed-this-before-it-had-a-name-j90</link>
      <guid>https://dev.to/huynguyengl99/agent-graph-engineering-part-0-i-needed-this-before-it-had-a-name-j90</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; Building a chatbot is easy. Building an AI system you can trace, test, and change without fear is not. I spent years shipping LLM features as intent detection plus nested &lt;code&gt;if/else&lt;/code&gt;, and no amount of observability tooling fixed it, because the problem was that the flow only existed in code, in my head, and in a stale diagram. The fix was to stop writing the flow as control flow and start declaring it as a graph. This post is the story of how I got there, years before the practice had a name, and why I think graphs are a durable answer rather than a trend. If you only want the stack and the arguments to bring to your team, skip to &lt;a href="https://dev.to/posts/agent-graph-engineering/the-stack-and-why-each-piece-is-there/"&gt;Part 1&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Building an AI assistant today is easy. A few lines of code, an API key, and you have something that chats. Add a couple of tool definitions and you have an agent. Done, right?&lt;/p&gt;

&lt;p&gt;Yes, for a hobby project. Production is a different animal, and the gap is wider than most people expect.&lt;/p&gt;

&lt;p&gt;The usual first answer is observability. Drop in Langfuse, Langsmith, or Logfire and you can finally see what the model said. That helps. It also does not touch the questions that actually keep an AI system from scaling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How do you split work across dedicated agents, so that document handling and web research are not the same tangle of prompt instructions?&lt;/li&gt;
&lt;li&gt;How do you pause a tool call for human approval before something irreversible like sending an email, while letting safe calls like reading a public profile run untouched?&lt;/li&gt;
&lt;li&gt;How does anyone see the full picture of your agent flow? Intent routing lives in conditionals. There is no artifact to point at in a design review.&lt;/li&gt;
&lt;li&gt;How do you trace one user message from arrival to final answer? Observability platforms give you a list of LLM spans. Reconstructing the flow means reading code backwards or writing a script to reshape the traces into something legible.&lt;/li&gt;
&lt;li&gt;How does your frontend know which messages the backend can send it? When the flow changes, what stops a schema from silently going stale on one side?&lt;/li&gt;
&lt;li&gt;How do you stream intermediate progress in a predictable way, instead of going quiet for forty seconds and then dumping everything at once?&lt;/li&gt;
&lt;li&gt;How do you test any of this? Mocked responses for fast feedback, real model runs to catch regressions when you swap models or edit a prompt.&lt;/li&gt;
&lt;li&gt;When the agent calls the wrong tool, or two tools, or none, what do you actually look at?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If none of those have bitten you yet, you probably have a chatbot rather than a system. I know because that was me.&lt;/p&gt;

&lt;p&gt;One thing before the story, because I would want to know. None of what follows is a composite or a hypothetical. It was a real product in the financial domain, with real users, a real team, and mistakes that were mine to make. There is a lot of generated agent writing around at the moment and I understand the reflex to skim a post hunting for the tell. Skim away. The details here are specific because they happened, and that is not something you can fake at length.&lt;/p&gt;

&lt;p&gt;The code that goes with this series is the same story. It is distilled from a production system of five services, out of about five years spent on realtime infrastructure and agent systems, three of those on one agent product and two on another. What carries over is not the domain, which is deliberately gone, but the failure modes. The parts that look over-careful are the parts that broke somewhere else first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I was
&lt;/h2&gt;

&lt;p&gt;Years ago, right around when ChatGPT landed, my team and I were building an AI product in the financial domain. I was not starting from zero: I had trained GPT-2 models, built realtime systems with Django Channels, shipped chatbots before.&lt;/p&gt;

&lt;p&gt;The product worked. Users used it. But it was built the only way anyone knew how at the time. The term "agent" did not exist yet. We had assistants with function calls, which later became tool calls, plus prompting techniques like chain of thought and tree of thought. Our domain required us to classify what the user wanted and route to the right handler, so the core of the system was an intent classifier feeding a tree of conditionals.&lt;/p&gt;

&lt;p&gt;We were not careless about it. We refactored, we applied every best practice we knew, and the code was reasonable by normal backend standards. But something felt wrong, and it took me a while to name it.&lt;/p&gt;

&lt;p&gt;Here is what was actually missing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No system view.&lt;/strong&gt; Nothing showed the shape of the flow. Understanding it meant reading from entry point to exit, holding every branch in your head, and drawing it by hand in draw.io. Then someone shipped a change and the diagram was a lie.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No end-to-end trace.&lt;/strong&gt; We could see individual model calls. We could not follow one user message through routing, tool calls, intermediate results, and the final answer as a single connected thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No generated contract.&lt;/strong&gt; Our REST API had OpenAPI. Change a serializer, regenerate, the frontend knows. Our AI layer had nothing equivalent. The WebSocket message shapes lived in two hand-maintained places and drifted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flaky streaming.&lt;/strong&gt; It worked, mostly, until it lagged or dropped in ways nobody could reproduce.&lt;/p&gt;

&lt;p&gt;Onboarding a teammate onto that system took weeks. Debugging a production report meant a code-reading expedition. That is not a scaling problem you fix with a better logger.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state machine itch
&lt;/h2&gt;

&lt;p&gt;At some point the shape of it clicked. Each step has a state. The model processes that state. Based on the result you transition to another state. That is a state machine, the thing I learned in university and had not thought about in years.&lt;/p&gt;

&lt;p&gt;Once you see it that way, the conditionals look like an implementation detail of something that wants to be declared instead of written. And a declared state machine can be drawn, inspected, and traced automatically, because the structure exists as data rather than as control flow.&lt;/p&gt;

&lt;p&gt;The framework landscape then was basically LangChain, and early LangChain was rough. If you were there, you know. Debugging through those abstractions when something went wrong was worse than debugging our own code.&lt;/p&gt;

&lt;p&gt;So I wanted a graph library for agents: something that routes on state, that can render itself, that lets a teammate understand the system without reading a thousand lines. I started building a small package for it and gave up, because doing it properly is a lot of work and I had a product to ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the pieces
&lt;/h2&gt;

&lt;p&gt;Time passed, the field moved, and the libraries got much better. CrewAI appeared. Pydantic AI appeared. Even LangChain grew into something genuinely useful, quite different from the version that burned me.&lt;/p&gt;

&lt;p&gt;Pydantic AI won me over immediately. Tool calls validated by Pydantic models, typed outputs, typed dependencies, provider independence, and an API that does not fight you. It treats an agent as a thing with a declared output type and a declared set of tools, which is exactly right.&lt;/p&gt;

&lt;p&gt;I still wanted the graph half. Then I ran into LangGraph, I think from a Reddit thread. It was the thing I had been sketching: state, nodes, conditional edges, and a flow you can export as a diagram. I went through Andrew Ng's course on it and the concepts landed fast, because I had already arrived at the same model from the other direction.&lt;/p&gt;

&lt;p&gt;One thing bothered me. LangGraph is a LangChain-ecosystem library, and I wanted Pydantic AI for the agent layer. So I went looking for people who had combined them, and found plenty. The combination came up again and again, with strong opinions in its favor, and some teams already running it in production.&lt;/p&gt;

&lt;p&gt;I tried it on a side project and it went better than expected. Two rough edges remained. Interrupts between Pydantic AI and LangGraph needed care. And streaming was awkward, because LangGraph only offers a generic custom-stream channel for anything outside LangChain.&lt;/p&gt;

&lt;p&gt;The streaming problem I had already solved elsewhere. &lt;a href="https://github.com/huynguyengl99/chanx" rel="noopener noreferrer"&gt;chanx&lt;/a&gt; is a WebSocket library I built for typed messages and generated AsyncAPI docs, and it can broadcast from anywhere in your process without threading a stream object through the call graph. Dropping it in meant a graph node deep in a subgraph could emit a progress event to the user without the graph flow knowing or caring.&lt;/p&gt;

&lt;p&gt;LangGraph, Pydantic AI, and chanx. That is the combination I had wanted for years, and it is still the one I reach for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this outlives the frameworks
&lt;/h2&gt;

&lt;p&gt;I have watched a lot of AI trends arrive and leave. Most did not feel solid. Graphs do, and I think that is because the idea is older than the hype around it. It is the same structure that shows up in build systems, dataflow engines, and state machines, applied to a new execution substrate.&lt;/p&gt;

&lt;p&gt;The signals are lining up too. Google's ADK material now teaches &lt;a href="https://www.youtube.com/watch?v=Mzr7byMFy_4" rel="noopener noreferrer"&gt;graph engineering&lt;/a&gt; directly. The discussion in my corner of the internet has shifted from prompt tricks toward flow structure.&lt;/p&gt;

&lt;p&gt;The vocabulary has caught up as well. Graph engineering has guides now, a growing pile of papers, and a definition that has more or less settled: designing the graph an agentic system executes in, the nodes that do the work, the edges that route between them, and the state that travels along them. There is a distinction in that writing I wish I had had years ago. Loop engineering designs the cycle a single agent repeats until it finishes. Graph engineering designs the coordination between those cycles. Most of my pain was from trying to solve the second problem with the tools for the first.&lt;/p&gt;

&lt;p&gt;What is still missing is production material. Nearly everything written is a tutorial: here is a state graph, here are nodes and edges, here is a two-node example that answers a question. Very little covers what happens when the graph has to live inside a real system, with a typed contract to a frontend, a person approving an action that cannot be taken back, traces you can hand someone after an incident, evals that let you change a model without crossing your fingers, and a deployment at the end of it. That gap is what this series is for.&lt;/p&gt;

&lt;p&gt;One more thing worth clearing up, because it is the reason this is hard to search for. Put "graph" and "AI" in a search box and you mostly get knowledge graphs and graph RAG, which are retrieval techniques where a graph is the data you query. This is the execution graph of the agent itself. Graph RAG could sit behind one node here as one tool among several. Same word, unrelated concept.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this series is
&lt;/h2&gt;

&lt;p&gt;A dozen posts, working from the stack and the reasoning down to a running system. We will build a reference implementation that is deliberately production-shaped rather than notebook-shaped: a React frontend, a Django backend that owns business data, and a separate FastAPI agent service running the graphs, talking over a typed WebSocket contract, with human-in-the-loop approvals, streaming progress, end-to-end tracing, mocked tests, and real-model evals.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/posts/agent-graph-engineering/the-stack-and-why-each-piece-is-there/"&gt;Part 1&lt;/a&gt; covers the stack and the case for each piece, which is the part you can forward to your tech lead. Everything after that is code.&lt;/p&gt;

&lt;p&gt;The frameworks in this series will age. Some of them will be replaced, and you may end up building your own. The structural idea underneath, that your agent flow should be declared as a graph rather than written as control flow, is the part I expect to outlive the tooling.&lt;/p&gt;

&lt;p&gt;Hope the story is useful, especially if you recognize yourself in the first half of it.&lt;/p&gt;

&lt;p&gt;I write these deep-dives and maintain a handful of Python libraries, so if that is your kind of thing, follow along on &lt;a href="https://github.com/huynguyengl99" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; and &lt;a href="https://www.linkedin.com/in/huynguyengl99/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>langgraph</category>
      <category>python</category>
      <category>ai</category>
      <category>agents</category>
    </item>
    <item>
      <title>Benchmarking Python API Frameworks with Real Workloads: FastAPI vs Litestar vs DRF vs Django Ninja vs Django Bolt</title>
      <dc:creator>Huy Nguyen</dc:creator>
      <pubDate>Tue, 11 Aug 2026 13:42:01 +0000</pubDate>
      <link>https://dev.to/huynguyengl99/benchmarking-python-api-frameworks-with-real-workloads-fastapi-vs-litestar-vs-drf-vs-django-ninja-43g</link>
      <guid>https://dev.to/huynguyengl99/benchmarking-python-api-frameworks-with-real-workloads-fastapi-vs-litestar-vs-drf-vs-django-ninja-43g</guid>
      <description>&lt;p&gt;Most Python framework benchmarks measure "hello world" JSON. Real APIs do authentication,&lt;br&gt;
database access and complex queries - so that's what I benchmarked, across FastAPI,&lt;br&gt;
Litestar, Django REST Framework, Django Ninja and Django Bolt, each with multiple&lt;br&gt;
production servers, under strict Docker resource limits.&lt;/p&gt;

&lt;p&gt;Everything is reproducible: &lt;a href="https://github.com/huynguyengl99/python-api-frameworks-benchmark" rel="noopener noreferrer"&gt;https://github.com/huynguyengl99/python-api-frameworks-benchmark&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The core design decisions
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Latest versions of everything&lt;/strong&gt; - Django Bolt 0.10, Django 6.0, FastAPI 0.141,
Litestar 2.24, DRF 3.18, Django Ninja 1.6.2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each framework uses its own native serializer.&lt;/strong&gt; Feeding Litestar Pydantic models
makes it convert twice (Pydantic then msgspec), so you'd be benchmarking Pydantic
instead of Litestar - a point the Litestar author raised on an earlier version of this
benchmark. Litestar and Bolt use msgspec structs, FastAPI and Ninja use Pydantic, DRF
uses its serializers. The response models are defined twice, field for field, and the
output is byte-identical: 4,796 bytes for &lt;code&gt;/articles/1&lt;/code&gt; from both FastAPI and Litestar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWT in an httpOnly cookie for the auth endpoints&lt;/strong&gt; - the setup I'd actually use in
production. Each framework uses its own ecosystem's library instead of something
hand-rolled: drf-auth-kit (DRF), django-ninja-jwt (Ninja), AuthX (FastAPI), and the
built-in cookie JWT support in Litestar and Bolt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Median across container restarts, not repeated runs.&lt;/strong&gt; Some servers (notably Bolt's
Rust worker pool under a 1-CPU cgroup) pick a throughput regime at container startup
and hold it, so best-of-N inside one container flatters the lucky starts. Details in
the repo's Methodology section.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;MacBook M2 Pro, 32GB RAM; PostgreSQL 16; 500 articles, 2000 comments, 100 tags, 50 authors&lt;/li&gt;
&lt;li&gt;Each framework alone in a Docker container: &lt;code&gt;--cpus=1&lt;/code&gt;, &lt;code&gt;--memory=750m&lt;/code&gt;, one at a time&lt;/li&gt;
&lt;li&gt;bombardier, 100 connections, 10s per measurement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 independent container starts per framework&lt;/strong&gt;, median reported with min-max spread&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Endpoints:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;What it measures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/json-1k&lt;/code&gt;, &lt;code&gt;/json-10k&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Pure serialization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/db&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10 simple rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/articles?page=1&amp;amp;page_size=20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pagination + nested author/tags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/articles/1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nested author + tags + comments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/auth/me&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cookie JWT verify + load user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/auth/articles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cookie JWT + the paginated query&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One fairness detail that matters: &lt;strong&gt;all five frameworks load the user row from the DB on&lt;br&gt;
both auth endpoints.&lt;/strong&gt; Litestar, DRF and Ninja do it as part of authenticating; AuthX and&lt;br&gt;
Bolt's guards only verify the signature, so I made FastAPI and Bolt load the user&lt;br&gt;
explicitly. Otherwise they'd do strictly less work and the comparison would be junk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;RPS, median over 5 container starts, zero errors across all 70 measurements:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;json-1k&lt;/th&gt;
&lt;th&gt;json-10k&lt;/th&gt;
&lt;th&gt;/db&lt;/th&gt;
&lt;th&gt;/articles&lt;/th&gt;
&lt;th&gt;/articles/1&lt;/th&gt;
&lt;th&gt;/auth/me&lt;/th&gt;
&lt;th&gt;/auth/articles&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bolt&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;38,576&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;19,089&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,986&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;208&lt;/td&gt;
&lt;td&gt;432&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3,024&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;196&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;litestar-uvicorn&lt;/td&gt;
&lt;td&gt;31,284&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24,547&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,039&lt;/td&gt;
&lt;td&gt;246&lt;/td&gt;
&lt;td&gt;443&lt;/td&gt;
&lt;td&gt;976&lt;/td&gt;
&lt;td&gt;193&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;litestar-granian&lt;/td&gt;
&lt;td&gt;19,006&lt;/td&gt;
&lt;td&gt;15,166&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,180&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;250&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;488&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,104&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;210&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fastapi-uvicorn&lt;/td&gt;
&lt;td&gt;13,845&lt;/td&gt;
&lt;td&gt;2,641&lt;/td&gt;
&lt;td&gt;984&lt;/td&gt;
&lt;td&gt;224&lt;/td&gt;
&lt;td&gt;428&lt;/td&gt;
&lt;td&gt;820&lt;/td&gt;
&lt;td&gt;193&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fastapi-granian&lt;/td&gt;
&lt;td&gt;8,484&lt;/td&gt;
&lt;td&gt;2,280&lt;/td&gt;
&lt;td&gt;952&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;td&gt;410&lt;/td&gt;
&lt;td&gt;749&lt;/td&gt;
&lt;td&gt;199&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;drf-gunicorn&lt;/td&gt;
&lt;td&gt;3,925&lt;/td&gt;
&lt;td&gt;3,132&lt;/td&gt;
&lt;td&gt;282&lt;/td&gt;
&lt;td&gt;140&lt;/td&gt;
&lt;td&gt;193&lt;/td&gt;
&lt;td&gt;261&lt;/td&gt;
&lt;td&gt;133&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;drf-granian&lt;/td&gt;
&lt;td&gt;2,703&lt;/td&gt;
&lt;td&gt;2,200&lt;/td&gt;
&lt;td&gt;830&lt;/td&gt;
&lt;td&gt;198&lt;/td&gt;
&lt;td&gt;321&lt;/td&gt;
&lt;td&gt;726&lt;/td&gt;
&lt;td&gt;179&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ninja-granian&lt;/td&gt;
&lt;td&gt;1,566&lt;/td&gt;
&lt;td&gt;1,422&lt;/td&gt;
&lt;td&gt;680&lt;/td&gt;
&lt;td&gt;130&lt;/td&gt;
&lt;td&gt;295&lt;/td&gt;
&lt;td&gt;610&lt;/td&gt;
&lt;td&gt;117&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ninja-uvicorn&lt;/td&gt;
&lt;td&gt;1,533&lt;/td&gt;
&lt;td&gt;1,424&lt;/td&gt;
&lt;td&gt;699&lt;/td&gt;
&lt;td&gt;126&lt;/td&gt;
&lt;td&gt;236&lt;/td&gt;
&lt;td&gt;584&lt;/td&gt;
&lt;td&gt;114&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;drf-uvicorn&lt;/td&gt;
&lt;td&gt;1,035&lt;/td&gt;
&lt;td&gt;973&lt;/td&gt;
&lt;td&gt;495&lt;/td&gt;
&lt;td&gt;153&lt;/td&gt;
&lt;td&gt;234&lt;/td&gt;
&lt;td&gt;447&lt;/td&gt;
&lt;td&gt;137&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The gap collapses once you hit the database
&lt;/h3&gt;

&lt;p&gt;37x between fastest and slowest on &lt;code&gt;/json-1k&lt;/code&gt;. &lt;strong&gt;1.9x&lt;/strong&gt; on &lt;code&gt;/articles/1&lt;/code&gt;. If your endpoint&lt;br&gt;
touches PostgreSQL, your framework is not the bottleneck - your queries are.&lt;/p&gt;

&lt;h3&gt;
  
  
  What authentication actually costs
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fou34kmqghe9l923tmt32.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fou34kmqghe9l923tmt32.png" alt="RPS on /auth/me: Django Bolt 3,008, 2.7x ahead of the rest" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Comparing &lt;code&gt;/auth/articles&lt;/code&gt; against the identical unauthenticated &lt;code&gt;/articles&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;public&lt;/th&gt;
&lt;th&gt;authed&lt;/th&gt;
&lt;th&gt;cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fastapi-granian&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;td&gt;199&lt;/td&gt;
&lt;td&gt;-1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;drf-gunicorn&lt;/td&gt;
&lt;td&gt;140&lt;/td&gt;
&lt;td&gt;133&lt;/td&gt;
&lt;td&gt;-5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bolt&lt;/td&gt;
&lt;td&gt;208&lt;/td&gt;
&lt;td&gt;196&lt;/td&gt;
&lt;td&gt;-6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;drf-granian&lt;/td&gt;
&lt;td&gt;198&lt;/td&gt;
&lt;td&gt;179&lt;/td&gt;
&lt;td&gt;-10%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ninja-uvicorn&lt;/td&gt;
&lt;td&gt;126&lt;/td&gt;
&lt;td&gt;114&lt;/td&gt;
&lt;td&gt;-10%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fastapi-uvicorn&lt;/td&gt;
&lt;td&gt;224&lt;/td&gt;
&lt;td&gt;193&lt;/td&gt;
&lt;td&gt;-14%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;litestar-granian&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;td&gt;210&lt;/td&gt;
&lt;td&gt;-16%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;litestar-uvicorn&lt;/td&gt;
&lt;td&gt;246&lt;/td&gt;
&lt;td&gt;193&lt;/td&gt;
&lt;td&gt;-22%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Roughly 5-20%, cheaper than I expected for "verify a token and load a user". Two things&lt;br&gt;
stand out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bolt's &lt;code&gt;/auth/me&lt;/code&gt; is 2.7x ahead of everything&lt;/strong&gt; (3,024 vs 1,104) because it validates
the JWT in Rust before Python runs at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Litestar pays the most&lt;/strong&gt; for a concrete reason: its &lt;code&gt;JWTCookieAuth&lt;/code&gt; runs in middleware,
before dependency injection, so &lt;code&gt;retrieve_user_handler&lt;/code&gt; opens its own DB session - that
request pays for two connection acquisitions instead of one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Memory and CPU
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgs1l5oydua2uguqk8ggj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgs1l5oydua2uguqk8ggj.png" alt="Memory and CPU per framework: most 195-260MB, drf-granian 456MB; Bolt lowest CPU at 67%" width="800" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Most configs peak at 195-260MB RAM&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;drf-granian is the outlier at 456MB&lt;/strong&gt; - untuned &lt;code&gt;--blocking-threads&lt;/code&gt;, per the Granian
maintainer (see "unaddressed feedback" below)&lt;/li&gt;
&lt;li&gt;Nearly everything saturates ~85% of the 1-CPU budget under load - &lt;strong&gt;except Bolt at 67%&lt;/strong&gt;,
while leading most endpoints&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  A word on Django Bolt
&lt;/h3&gt;

&lt;p&gt;If you're open to a young framework, this is the one I'd watch. It won or tied the top&lt;br&gt;
spot on 4 of 7 endpoints while leaving ~18% more CPU headroom than every other config -&lt;br&gt;
room to grow under load, not just a good number on a chart. And unlike moving to Litestar&lt;br&gt;
or FastAPI, you keep the Django ORM, admin and package ecosystem.&lt;/p&gt;

&lt;p&gt;Honest trade-offs: it's young and moving fast, the Rust internals (Actix Web + PyO3 +&lt;br&gt;
msgspec) mean you can't monkey-patch your way out of a corner, and under a hard 1-CPU cap&lt;br&gt;
its throughput varies between container starts under a hard 1-CPU cap. For a side project or a new&lt;br&gt;
internal service, I'd have no problem reaching for it today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feedback I have NOT addressed yet
&lt;/h2&gt;

&lt;p&gt;Being upfront, because it affects one result. The Granian maintainer (u/gi0baro) explained&lt;br&gt;
that drf-granian's memory number comes from me not setting &lt;code&gt;--blocking-threads&lt;/code&gt; or&lt;br&gt;
backpressure, so it spawns a lot of threads and burns time on GIL contention - drf-granian's&lt;br&gt;
456MB is that same unfixed issue. He also noted that &lt;code&gt;--cpus=1&lt;/code&gt; in Docker is a time-slice&lt;br&gt;
scheduler limit rather than a real core pin, and that it penalizes Granian more than other&lt;br&gt;
servers since Granian runs I/O in a separate runtime with extra threads.&lt;/p&gt;

&lt;p&gt;I'm keeping the CPU cap because it makes runs reproducible and comparable, but he's right&lt;br&gt;
on both counts. Tuning &lt;code&gt;--blocking-threads&lt;/code&gt; is top of my list for the next iteration, along with cold&lt;br&gt;
start time and image size (both suggested by the Litestar author).&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Framework choice barely matters once you touch I/O.&lt;/strong&gt; Optimize your queries - the
N+1 you didn't notice costs more than any framework migration will ever save.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For raw serialization, Bolt and Litestar lead&lt;/strong&gt;, then FastAPI, then a big gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookie JWT auth costs 5-20%&lt;/strong&gt; on a DB-heavy endpoint; where the token is validated
(Rust vs Python, middleware vs dependency) decides where in that range you land.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;uvicorn vs granian is workload-dependent&lt;/strong&gt;: uvicorn wins CPU-bound JSON on ASGI
frameworks, granian wins DB-bound endpoints, and granian clearly beats uvicorn for
WSGI DRF.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure across container restarts and publish the spread&lt;/strong&gt; - it changed these
results more than any framework upgrade did.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything - code, Docker setup, runner, raw per-framework JSON results, and dated&lt;br&gt;
snapshots - is in the repo:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/huynguyengl99/python-api-frameworks-benchmark" rel="noopener noreferrer"&gt;https://github.com/huynguyengl99/python-api-frameworks-benchmark&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Issues and PRs are very welcome, especially if you know these servers better than I do.&lt;br&gt;
And if you find it useful, a star would be appreciated 😄&lt;/p&gt;

</description>
      <category>python</category>
      <category>django</category>
      <category>fastapi</category>
      <category>benchmark</category>
    </item>
    <item>
      <title>Streaming Agents with Pydantic AI over Typed WebSockets</title>
      <dc:creator>Huy Nguyen</dc:creator>
      <pubDate>Sat, 08 Aug 2026 14:34:43 +0000</pubDate>
      <link>https://dev.to/huynguyengl99/streaming-agents-with-pydantic-ai-over-typed-websockets-215i</link>
      <guid>https://dev.to/huynguyengl99/streaming-agents-with-pydantic-ai-over-typed-websockets-215i</guid>
      <description>&lt;p&gt;I've been building realtime products for years: group chat, voice pipelines, streaming AI assistants. Two kinds of pain kept following me around.&lt;/p&gt;

&lt;p&gt;The first was WebSockets. Every project grew the same untyped &lt;code&gt;receive_json&lt;/code&gt; handler with an if/else chain, hand-written validation, and no contract the frontend could rely on. Messages were stringly-typed, bugs surfaced at runtime, and onboarding a teammate meant "read the consumer, end to end". That pain is why I built &lt;a href="https://github.com/huynguyengl99/chanx" rel="noopener noreferrer"&gt;chanx&lt;/a&gt;: typed messages, automatic routing, AsyncAPI docs (the full story is in &lt;a href="https://huynguyengl99.github.io/posts/chanx-structured-websockets-django-fastapi/" rel="noopener noreferrer"&gt;my intro post&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The second was agent frameworks. I shipped my first LLM features on LangChain and spent more time debugging its abstractions than my own code. &lt;a href="https://pydantic.dev/docs/ai/" rel="noopener noreferrer"&gt;Pydantic AI&lt;/a&gt; won me over the first time I tried it: typed tools, typed outputs, typed dependency injection. It applies to agents the same philosophy chanx applies to WebSockets, and since both are built on Pydantic they snap together with almost no glue.&lt;/p&gt;

&lt;p&gt;There's just one gap. At the UI layer, pydantic-ai's official story is SSE-shaped: adapters for &lt;a href="https://pydantic.dev/docs/ai/integrations/ui/overview/" rel="noopener noreferrer"&gt;AG-UI and the Vercel AI protocol&lt;/a&gt;, and a built-in &lt;a href="https://pydantic.dev/docs/ai/guides/web/" rel="noopener noreferrer"&gt;&lt;code&gt;Agent.to_web()&lt;/code&gt;&lt;/a&gt; chat page that the docs explicitly label as a dev tool. Real agent products want more than a one-way stream. A conversation is &lt;em&gt;bidirectional&lt;/em&gt;: the user sends prompts, the agent streams back text and tool activity, and (the interesting part) the agent sometimes has to &lt;strong&gt;stop and ask permission&lt;/strong&gt; before running a destructive tool. That round trip (pause, ask, resume) fits a WebSocket perfectly, and there is currently no WebSocket example anywhere in the pydantic-ai docs.&lt;/p&gt;

&lt;p&gt;So I built one, the same way I build REST APIs: &lt;strong&gt;contract-first&lt;/strong&gt;. The server defines every message as a Pydantic model, chanx turns those models into routed handlers plus an AsyncAPI 3.0 schema, and the React client generates its TypeScript types &lt;em&gt;from&lt;/em&gt; that schema. Backend and frontend can't drift, teammates integrate from the contract instead of reading your Python, and the whole thing (streaming, tool calls, approvals) is testable without ever calling an LLM.&lt;/p&gt;

&lt;p&gt;Companion repo with everything runnable: &lt;a href="https://github.com/huynguyengl99/pydantic-ai-ws-agent" rel="noopener noreferrer"&gt;huynguyengl99/pydantic-ai-ws-agent&lt;/a&gt;. Snippets below are trimmed for reading and current as of pydantic-ai 2.9 and chanx 2.8; the repo is the source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The big picture
&lt;/h2&gt;

&lt;p&gt;The demo agent ("Tasklet") manages a task list in SQLite. Safe tools run freely; destructive ones pause for approval.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm7bkl8ez8t4ahg48bk20.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm7bkl8ez8t4ahg48bk20.png" alt="Tasklet: live tool cards, a destructive tool paused for approval, and the per-conversation task list" width="800" height="566"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pydantic-ai Agent ──run_stream events──▶ AgentHarness (background task)
      │                                        │ broadcast_event
  typed tools                                  ▼
  requires_approval=True          group: conversation.{id}   ◀── HTTP POST /notify
                                               │ channel layer (in-memory / Redis)
                              ┌────────────────┼────────────────┐
                              ▼                ▼                ▼
                      chanx consumer    chanx consumer   chanx consumer      AsyncAPI 3.0 schema
                        (tab 1)           (tab 2)        (other device)  ──▶ generated TS types
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four pieces: a plain pydantic-ai &lt;strong&gt;agent&lt;/strong&gt; (typed tools, typed deps, &lt;code&gt;requires_approval&lt;/code&gt; on the dangerous ones, structured output); a &lt;strong&gt;harness&lt;/strong&gt; that runs it and translates each event into a typed message; a &lt;strong&gt;channel layer&lt;/strong&gt; that carries those events to a per-conversation group rather than a single socket; and a chanx &lt;strong&gt;consumer&lt;/strong&gt; that validates, routes, and documents the protocol the React client generates its types from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent: tools that know they're dangerous
&lt;/h2&gt;

&lt;p&gt;Nothing here is WebSocket-aware. It's a standard pydantic-ai agent with two deliberate choices: a structured output type, and typed deps carrying the conversation id so every tool operates on that conversation's tasks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TextAnswer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Final answer to the user, with suggested follow-up prompts.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;follow_ups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AgentDeps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="c1"&gt;# Every run ends as either a final answer or a request for tool approval.
&lt;/span&gt;&lt;span class="n"&gt;AgentOutput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TextAnswer&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;DeferredToolRequests&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Model&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AgentDeps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AgentOutput&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AgentDeps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AgentOutput&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resolved_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;deps_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;AgentDeps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;TextAnswer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DeferredToolRequests&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;INSTRUCTIONS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@agent.tool&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RunContext&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AgentDeps&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskItem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Add a new task with the given title.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@agent.tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requires_approval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;delete_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RunContext&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AgentDeps&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Permanently delete the task with the given id. Requires user approval.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Task &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; deleted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The repo has six tools; &lt;code&gt;clear_all_tasks&lt;/code&gt; is the other one behind approval.)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;requires_approval=True&lt;/code&gt; is pydantic-ai's &lt;a href="https://pydantic.dev/docs/ai/tools-toolsets/deferred-tools/" rel="noopener noreferrer"&gt;deferred tools&lt;/a&gt; feature. When the model calls &lt;code&gt;delete_task&lt;/code&gt;, the tool does &lt;strong&gt;not&lt;/strong&gt; execute. Instead the run ends early and &lt;code&gt;result.output&lt;/code&gt; is a &lt;code&gt;DeferredToolRequests&lt;/code&gt; carrying the pending calls, which is exactly why &lt;code&gt;output_type&lt;/code&gt; is a union: every run ends as either a final answer or a request for permission, and the type system makes you handle both.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TextAnswer&lt;/code&gt; instead of plain &lt;code&gt;str&lt;/code&gt; is the structured-output half of the story. Alongside the answer text, the model fills &lt;code&gt;follow_ups&lt;/code&gt; with three suggested next prompts that the UI renders as clickable chips. The final answer isn't prose with conventions bolted on; it's a validated object, and the chips ride along in the same schema.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;build_agent()&lt;/code&gt; being a factory instead of a module-level singleton matters later: tests build the same agent with a fake model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The message contract
&lt;/h2&gt;

&lt;p&gt;Every WebSocket message is a Pydantic model with a &lt;code&gt;Literal&lt;/code&gt; action field. chanx uses that field as a discriminator for validation, routing, and docs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseMessage&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;User sends a prompt to the agent.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ChatPayload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full protocol is small. Client → server: &lt;code&gt;chat&lt;/code&gt;, &lt;code&gt;tool_decision&lt;/code&gt;. Server → client: &lt;code&gt;user_message&lt;/code&gt;, &lt;code&gt;stream_start&lt;/code&gt;, &lt;code&gt;text_delta&lt;/code&gt;, &lt;code&gt;tool_call&lt;/code&gt;, &lt;code&gt;tool_result&lt;/code&gt;, &lt;code&gt;approval_request&lt;/code&gt;, &lt;code&gt;stream_end&lt;/code&gt;, &lt;code&gt;suggestions&lt;/code&gt;, &lt;code&gt;history&lt;/code&gt;, &lt;code&gt;tasks_updated&lt;/code&gt;, &lt;code&gt;notification&lt;/code&gt;, &lt;code&gt;agent_error&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The consumer binds it together: handlers declare what they accept and what they may reply with, and that declaration &lt;em&gt;is&lt;/em&gt; the documentation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Pydantic AI task assistant over a typed WebSocket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AgentConsumer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseConsumer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;passthrough_events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ClassVar&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BaseMessage&lt;/span&gt;&lt;span class="p"&gt;]]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BROADCAST_MESSAGES&lt;/span&gt;

    &lt;span class="nd"&gt;@ws_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Send a prompt to the agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;AgentServerMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# the union of everything we may send back
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_spawn_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="nd"&gt;@ws_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Approve or deny pending tool calls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;AgentServerMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_tool_decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ToolDecisionMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_spawn_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decisions&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No if/else routing, no manual validation, and &lt;code&gt;/asyncapi.json&lt;/code&gt; now serves an AsyncAPI 3.0 spec describing every message above: the WebSocket equivalent of what drf-spectacular gives you for REST (I wrote about that workflow in &lt;a href="https://huynguyengl99.github.io/posts/schema-first-api-development-drf-react/" rel="noopener noreferrer"&gt;the schema-first post&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming structured output: snapshots into deltas
&lt;/h2&gt;

&lt;p&gt;Streaming plain text is easy. Streaming a &lt;em&gt;structured&lt;/em&gt; answer is the interesting part: the final output is a &lt;code&gt;TextAnswer&lt;/code&gt; object, so there is no raw text stream to forward. pydantic-ai's answer is &lt;code&gt;run_stream()&lt;/code&gt; plus two hooks.&lt;/p&gt;

&lt;p&gt;Tool events arrive through an &lt;code&gt;event_stream_handler&lt;/code&gt;, which the harness maps straight onto the message contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;forward_tool_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;_ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RunContext&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AgentDeps&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncIterable&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AgentStreamEvent&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;FunctionToolCallEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ToolCallMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ToolCallPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;tool_call_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_call_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;args_as_dict&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="p"&gt;)))&lt;/span&gt;
            &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;FunctionToolResultEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ToolResultMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ToolResultPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;tool_call_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_call_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;to_jsonable_python&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The answer itself streams as &lt;em&gt;partially validated snapshots&lt;/em&gt;: &lt;code&gt;stream_output()&lt;/code&gt; yields a growing &lt;code&gt;TextAnswer&lt;/code&gt; each time more of the model's JSON arrives. Diffing successive &lt;code&gt;content&lt;/code&gt; values recovers the text deltas to send:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;sent_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;user_prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;message_history&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;deferred_tool_results&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;deferred_tool_results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;AgentDeps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;event_stream_handler&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;forward_tool_events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;partial&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream_output&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;debounce_by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="n"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;TextAnswer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;sent_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeprefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sent_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sent_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TextDeltaMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;TextDeltaPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
                &lt;span class="n"&gt;sent_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;
        &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;partial&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all_messages_json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client sees &lt;code&gt;text_delta&lt;/code&gt; for prose and &lt;code&gt;tool_call&lt;/code&gt;/&lt;code&gt;tool_result&lt;/code&gt; pairs for tool activity (rendered as live cards in the UI), and the loop stays completely ignorant of &lt;em&gt;which&lt;/em&gt; tools exist.&lt;/p&gt;

&lt;p&gt;After the loop, the union output type earns its keep:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;DeferredToolRequests&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# run_stream stops forwarding events once the final result (the paused
&lt;/span&gt;        &lt;span class="c1"&gt;# calls) is found, so announce them here: same ids, same wire order.
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approvals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ToolCallMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ToolCallPayload&lt;/span&gt;&lt;span class="p"&gt;(...)))&lt;/span&gt;
        &lt;span class="n"&gt;PENDING_APPROVALS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;approval_request_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;TextAnswer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;follow_ups&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;follow_ups&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StreamEndMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;StreamEndPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...)))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;follow_ups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save_suggestions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;follow_ups&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SuggestionsMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;SuggestionsPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;follow_ups&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;follow_ups&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One subtlety worth calling out: a deferred tool call never reaches the &lt;code&gt;event_stream_handler&lt;/code&gt; (deferred means "not executed", and only executing tools emit events), so the harness announces the paused calls itself before the &lt;code&gt;approval_request&lt;/code&gt;. The client renders the same tool cards either way; it can't tell which path produced them. And on the happy path, the suggestions arrive as their own message &lt;em&gt;after&lt;/em&gt; &lt;code&gt;stream_end&lt;/code&gt;, so the chips pop in once the answer settles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Broadcast, don't send: background runs and groups
&lt;/h2&gt;

&lt;p&gt;My first version awaited the agent run &lt;em&gt;inside&lt;/em&gt; the chat handler and wrote messages straight to the socket. It worked, but with three quiet problems: the handler blocks for the whole run, a page refresh mid-run kills the stream, and a second tab sees nothing.&lt;/p&gt;

&lt;p&gt;The fix is the pattern chanx (and Django Channels before it) is built around: &lt;strong&gt;broadcast to a group, not to a connection&lt;/strong&gt;. Every consumer joins a per-conversation group on connect, and the run executes as a background task that broadcasts events to that group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post_authentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conversation.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channel_layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group_add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channel_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;harness&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AgentHarness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_broadcast&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BaseMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;broadcast_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conversation.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_spawn_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coro&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# The task outlives this connection: a client that refreshes mid-run
&lt;/span&gt;    &lt;span class="c1"&gt;# keeps receiving events through the conversation group.
&lt;/span&gt;    &lt;span class="n"&gt;RUNNING&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;guarded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coro&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the receiving side there is nothing to write. chanx's &lt;code&gt;passthrough_events&lt;/code&gt; generates the event handlers: any message type in the list that arrives via the group is forwarded to the WebSocket client verbatim, and documented in AsyncAPI like everything else.&lt;/p&gt;

&lt;p&gt;What this buys, concretely (all covered by tests):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The chat handler returns immediately&lt;/strong&gt;; a per-conversation &lt;code&gt;RUNNING&lt;/code&gt; dict rejects concurrent runs with a typed &lt;code&gt;agent_error&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh-proof streams&lt;/strong&gt;: the run keeps going without any socket attached; reconnect replays history and picks up live events&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tab for free&lt;/strong&gt;: every tab renders the same deltas, tool cards, and approval requests. Even the user's own prompt travels this way, echoed as a &lt;code&gt;user_message&lt;/code&gt; before &lt;code&gt;stream_start&lt;/code&gt;, so the sending tab renders the bubble from the same broadcast as everyone else instead of appending locally&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approve from anywhere&lt;/strong&gt;: pending approvals are keyed by conversation, not connection; request approval on one device, approve on another&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Beyond the socket: HTTP, workers, other frameworks
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;broadcast_event&lt;/code&gt; is a classmethod, so &lt;em&gt;anything&lt;/em&gt; can push typed messages into a conversation. The demo exposes &lt;code&gt;POST /conversations/{id}/notify&lt;/code&gt;, and a &lt;code&gt;curl&lt;/code&gt; fires a toast on every connected tab. The agent uses the same door from the inside: a &lt;code&gt;schedule_reminder&lt;/code&gt; tool reads the conversation id from &lt;code&gt;ctx.deps&lt;/code&gt;, schedules a job, and returns immediately; when the job fires long after &lt;code&gt;stream_end&lt;/code&gt;, it broadcasts a &lt;code&gt;notification&lt;/code&gt; into the group.&lt;/p&gt;

&lt;p&gt;That escape hatch scales in two directions. Group broadcast isn't the only delivery mode: chanx can address a single connection, so a handler can hand heavy work to a real task queue (taskiq, Celery, ARQ) using &lt;code&gt;self.channel_name&lt;/code&gt; as a typed reply address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# consumer: queue the job with a reply address
&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;run_agent_job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;kiq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reply_to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channel_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# worker process: send typed events back to that exact connection
&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;AgentConsumer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JobProgressEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...),&lt;/span&gt; &lt;span class="n"&gt;reply_to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# consumer: typed, routed, and documented like everything else
&lt;/span&gt;&lt;span class="nd"&gt;@event_handler&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_job_progress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;JobProgressEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ProgressMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ProgressMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consumer stays a thin protocol adapter while the LLM work happens in a process built for it. And since the channel layer is just Redis, and chanx speaks the same API on Django Channels and FastAPI, the socket and the agent don't even have to share a service. A split I run in production: Django owns the user-facing WebSocket (sessions, ORM auth, a consumer that's little more than &lt;code&gt;passthrough_events&lt;/code&gt;) while a separate FastAPI service runs the models and broadcasts into the same conversation groups. Django never awaits an LLM; the agent service never touches a session. The contract is the only thing they share, and that's the part AsyncAPI documents.&lt;/p&gt;

&lt;p&gt;The demo runs on an in-memory layer with zero dependencies; set &lt;code&gt;REDIS_URL&lt;/code&gt; and the same code spans processes and instances.&lt;/p&gt;

&lt;h2&gt;
  
  
  Human-in-the-loop: the approval round trip
&lt;/h2&gt;

&lt;p&gt;This is the part WebSockets were made for. The full sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Model calls &lt;code&gt;delete_task&lt;/code&gt; → the run ends with &lt;code&gt;DeferredToolRequests&lt;/code&gt;, and the harness announces the paused calls as &lt;code&gt;tool_call&lt;/code&gt; messages, so the UI shows the tool card immediately (status: &lt;em&gt;awaiting approval&lt;/em&gt;)&lt;/li&gt;
&lt;li&gt;The server sends &lt;code&gt;approval_request&lt;/code&gt;, persists the message history, and simply stops. No polling, no hanging request&lt;/li&gt;
&lt;li&gt;The user clicks Approve or Deny → client sends &lt;code&gt;tool_decision&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The harness builds &lt;code&gt;DeferredToolResults&lt;/code&gt; and &lt;strong&gt;resumes the run&lt;/strong&gt; with the saved history:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decisions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ToolDecision&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;approvals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_call_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;ToolApproved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;override_args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;override_args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approved&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nc"&gt;ToolDenied&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Denied by the user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;decisions&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;del&lt;/span&gt; &lt;span class="n"&gt;PENDING_APPROVALS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;DeferredToolResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;approvals&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;approvals&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resumed run is just another &lt;code&gt;run_stream()&lt;/code&gt; call: approved tools execute (their events stream through the same handler), denied tools return the denial message to the model, and the model streams a final response either way. &lt;code&gt;ToolApproved(override_args=...)&lt;/code&gt; is a lovely detail: the user can &lt;em&gt;edit&lt;/em&gt; the arguments as part of approving, which is how real review UIs should work.&lt;/p&gt;

&lt;p&gt;Approvals also survive reconnects, in both directions. Pending requests are keyed by conversation rather than connection, and the consumer re-sends the open &lt;code&gt;approval_request&lt;/code&gt; to every new connection, so a refresh mid-approval gets a working approval card instead of a dead end. The E2E test literally approves from a second browser tab. One UI subtlety falls out of multi-tab: when a run resumes, other tabs still showing an open approval card mark it "resolved from another session" on the next &lt;code&gt;stream_end&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The gotcha: tell the model the harness has it covered
&lt;/h3&gt;

&lt;p&gt;My first instructions said "destructive operations require user approval". The model took that personally. Instead of calling &lt;code&gt;delete_task&lt;/code&gt;, it &lt;em&gt;asked in chat&lt;/em&gt;: "This is permanent, do I have your approval? (yes/no)". Technically obedient, completely bypassing the typed approval flow.&lt;/p&gt;

&lt;p&gt;The fix is to be explicit about the division of labor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;When the user asks for a destructive operation, call the tool directly — do NOT
ask for confirmation in chat. The system automatically pauses destructive tool
calls and asks the user for approval in the UI.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you build approval flows, this prompt bug will find you. The model needs to know that safety is &lt;em&gt;infrastructure&lt;/em&gt;, not its job.&lt;/p&gt;

&lt;h2&gt;
  
  
  AsyncAPI → TypeScript: the client writes itself
&lt;/h2&gt;

&lt;p&gt;chanx serves the contract at &lt;code&gt;/asyncapi.json&lt;/code&gt;; every payload above appears in &lt;code&gt;components.schemas&lt;/code&gt; as plain JSON Schema. A ~100-line script fetches the spec, collects the client-bound and server-bound messages from the AsyncAPI &lt;em&gt;operations&lt;/em&gt; (inputs vs replies), and feeds them to &lt;code&gt;json-schema-to-typescript&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// generated - src/generated/messages.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ToolCallMessage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tool_call&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ToolCallPayload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ClientMessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ChatMessage&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;ToolDecisionMessage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ServerMessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* union of all twelve server messages */&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One codegen detail worth knowing: Pydantic marks &lt;code&gt;action&lt;/code&gt; as optional in JSON Schema (it has a default), but it's the discriminator, so the script forces it into &lt;code&gt;required&lt;/code&gt; for TypeScript to narrow on. With that, the entire client state layer is a single exhaustive switch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ChatState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ServerMessage&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ChatState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text_delta&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="c1"&gt;// append to the streaming bubble&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tool_call&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;// add a tool card&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approval_request&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;// render Approve/Deny&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;suggestions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;// show follow-up chips&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stream_end&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="c1"&gt;// finalize + count tokens&lt;/span&gt;
    &lt;span class="c1"&gt;// TypeScript errors if any server message is unhandled&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rename a field in a Pydantic payload, run &lt;code&gt;pnpm generate&lt;/code&gt;, and &lt;code&gt;tsc&lt;/code&gt; lists every client line that just became wrong. Same loop as schema-first REST: the transport changed, the discipline didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persistence: history as a typed artifact
&lt;/h2&gt;

&lt;p&gt;pydantic-ai ships &lt;code&gt;ModelMessagesTypeAdapter&lt;/code&gt;, a &lt;code&gt;TypeAdapter&lt;/code&gt; for full message histories, so conversation state is just another validated blob: &lt;code&gt;stream.all_messages_json()&lt;/code&gt; on the way out, &lt;code&gt;ModelMessagesTypeAdapter.validate_json(raw)&lt;/code&gt; on the way back in. Two functions, no bespoke serialization.&lt;/p&gt;

&lt;p&gt;On connect, the consumer replays the conversation as a &lt;code&gt;history&lt;/code&gt; message plus the current task list (&lt;code&gt;tasks_updated&lt;/code&gt;) and the latest follow-up chips. The transcript builder walks the typed history and reconstructs the &lt;em&gt;interleaving&lt;/em&gt;: user text, assistant text, and tool cards in order, each card carrying a &lt;code&gt;done&lt;/code&gt;, &lt;code&gt;denied&lt;/code&gt;, or &lt;code&gt;awaiting&lt;/code&gt; status, so a denied deletion still renders as denied after a refresh. Since everything is stored per conversation, a sidebar with &lt;code&gt;/conversations&lt;/code&gt; (titles derived from the first prompt) and conversation delete falls out almost for free. Multi-turn context, reconnects, and resume-after-approval all sit on these same two functions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the whole flow without an LLM
&lt;/h2&gt;

&lt;p&gt;The part I care most about: the entire WebSocket protocol (streaming, tool execution, approval, denial, history replay) runs in pytest with &lt;strong&gt;no API key&lt;/strong&gt;. pydantic-ai's &lt;code&gt;FunctionModel&lt;/code&gt; plays the model; a stream function scripts exactly what "the LLM" does. One wrinkle of structured output: the final answer is itself a call to the output tool, so the script emits that too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;delete_task_flow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;tool_return&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ToolReturnPart&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tool_return&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# After the tool ran, answer via the structured-output tool
&lt;/span&gt;        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DeltaToolCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_tools&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;json_args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Result: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool_return&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DeltaToolCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete_task&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json_args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: 1}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;chanx's &lt;code&gt;WebsocketCommunicator&lt;/code&gt; drives the consumer, and the assertions read like the protocol spec:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;comm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ChatPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Delete task 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="n"&gt;replies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;comm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive_all_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stop_action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approval_request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# the prompt echoes to the group, the paused call is announced, the run pauses
&lt;/span&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;replies&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream_start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_call&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approval_request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;assert &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_tasks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conv-delete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;     &lt;span class="c1"&gt;# nothing deleted yet
&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;comm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ToolDecisionMessage&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="n"&gt;approved&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;...))&lt;/span&gt;
&lt;span class="n"&gt;replies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;comm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive_all_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stop_action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream_end&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_tasks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conv-delete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;       &lt;span class="c1"&gt;# now it's gone
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest of the suite covers the deny path, &lt;strong&gt;two tabs receiving identical broadcast feeds&lt;/strong&gt;, &lt;strong&gt;approving from a fresh connection after a disconnect&lt;/strong&gt;, the concurrent-run guard, transcript replay with denied and awaiting tool cards, and a contract test that pulls &lt;code&gt;/asyncapi.json&lt;/code&gt; and asserts every message type is present, so the schema the frontend generates from can't silently lose a message. Twenty tests, a couple of seconds, no LLM.&lt;/p&gt;

&lt;p&gt;One trap worth sharing: &lt;code&gt;Agent.override(model=...)&lt;/code&gt; uses contextvars, which don't propagate into the consumer's task under the test communicator. The factory pattern sidesteps it: tests call &lt;code&gt;build_agent(FunctionModel(...))&lt;/code&gt; and patch the consumer's agent directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production notes
&lt;/h2&gt;

&lt;p&gt;The demo makes three simplifications you'd revisit for production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scaling out&lt;/strong&gt;: the repo ships a &lt;code&gt;docker-compose.yml&lt;/code&gt; for Redis, and the README walks through the two-instance demo (chat on one port, watch the same stream on another).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth&lt;/strong&gt;: the consumer trusts the &lt;code&gt;conversation&lt;/code&gt; query param. chanx has an authentication hook (&lt;code&gt;post_authentication&lt;/code&gt;) where you'd validate a session or token before joining the group and replaying history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Durable pending approvals&lt;/strong&gt;: pending &lt;code&gt;DeferredToolRequests&lt;/code&gt; live in an in-process dict; store them in the database if approvals must survive restarts. And if you ever accept message history &lt;em&gt;from&lt;/em&gt; the client, run it through pydantic-ai's &lt;code&gt;sanitize_messages&lt;/code&gt; first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WebSockets are the natural transport for agents with approvals&lt;/strong&gt;: pause and resume are just messages, in both directions, on one connection. pydantic-ai's &lt;code&gt;run_stream()&lt;/code&gt; + &lt;code&gt;DeferredToolRequests&lt;/code&gt;/&lt;code&gt;DeferredToolResults&lt;/code&gt; hand you streaming and human-in-the-loop as library features; the harness adapting them to a transport is ~150 lines&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured output streams too&lt;/strong&gt;: partial-validation snapshots diff into text deltas, and extra fields like follow-up suggestions ride along in the same schema&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the WebSocket protocol as a contract&lt;/strong&gt;: Pydantic models with discriminated actions on the server, AsyncAPI in the middle, generated TypeScript on the client&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run in the background, broadcast to a group&lt;/strong&gt;: refresh-proof streams, multi-tab sync, approve-from-anywhere, and server-initiated notifications all come from one decision; events go to the conversation, not the socket. The same layer lets you offload runs to a worker or put a Django socket in front of a FastAPI agent&lt;/li&gt;
&lt;li&gt;Tell the model that approval is handled by infrastructure, or it will improvise its own confirmation dialog in prose&lt;/li&gt;
&lt;li&gt;Script the model with &lt;code&gt;FunctionModel&lt;/code&gt; and test the entire flow offline; deterministic agent tests are entirely achievable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I hope this saves you some of the pain it took me to learn. If you try the pattern (or have your own tricks for agent WebSockets), the repo issues are open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Companion repo: &lt;a href="https://github.com/huynguyengl99/pydantic-ai-ws-agent" rel="noopener noreferrer"&gt;huynguyengl99/pydantic-ai-ws-agent&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pydantic.dev/docs/ai/" rel="noopener noreferrer"&gt;Pydantic AI docs&lt;/a&gt; · &lt;a href="https://pydantic.dev/docs/ai/tools-toolsets/deferred-tools/" rel="noopener noreferrer"&gt;deferred tools&lt;/a&gt; · &lt;a href="https://pydantic.dev/docs/ai/testing/" rel="noopener noreferrer"&gt;testing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/huynguyengl99/chanx" rel="noopener noreferrer"&gt;chanx&lt;/a&gt; - typed WebSockets for Django and FastAPI (&lt;a href="https://huynguyengl99.github.io/posts/chanx-structured-websockets-django-fastapi/" rel="noopener noreferrer"&gt;my intro post&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://huynguyengl99.github.io/posts/schema-first-api-development-drf-react/" rel="noopener noreferrer"&gt;Schema-first API development with DRF + React&lt;/a&gt; - the same philosophy for REST&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>fastapi</category>
      <category>webdev</category>
    </item>
    <item>
      <title>🚀 Supercharge Your FastAPI WebSockets with Channel Layers &amp; Group Messaging</title>
      <dc:creator>Huy Nguyen</dc:creator>
      <pubDate>Thu, 18 Sep 2025 02:38:11 +0000</pubDate>
      <link>https://dev.to/huynguyengl99/supercharge-your-fastapi-websockets-with-channel-layers-group-messaging-19kb</link>
      <guid>https://dev.to/huynguyengl99/supercharge-your-fastapi-websockets-with-channel-layers-group-messaging-19kb</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvl9ejtloufsvls5rnuf2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvl9ejtloufsvls5rnuf2.png" alt=" " width="800" height="626"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Building real-time features in FastAPI? Hit the wall trying to send messages from HTTP endpoints to WebSocket clients? Struggling with group messaging and scalability?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fast Channels&lt;/strong&gt; brings Django Channels' battle-tested architecture to FastAPI and the entire ASGI ecosystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pain Points We've All Hit
&lt;/h2&gt;

&lt;p&gt;FastAPI's native WebSocket support gets you started, but quickly becomes limiting:&lt;/p&gt;

&lt;p&gt;❌ &lt;strong&gt;No group messaging&lt;/strong&gt; - can't broadcast to multiple connections&lt;br&gt;&lt;br&gt;
❌ &lt;strong&gt;No cross-process communication&lt;/strong&gt; - HTTP endpoints can't reach WebSocket clients&lt;br&gt;&lt;br&gt;
❌ &lt;strong&gt;Manual connection management&lt;/strong&gt; - tracking users, rooms, cleanup&lt;br&gt;&lt;br&gt;
❌ &lt;strong&gt;No scalability&lt;/strong&gt; - everything breaks with multiple server instances&lt;br&gt;&lt;br&gt;
❌ &lt;strong&gt;Testing nightmare&lt;/strong&gt; - WebSocket unit tests are painful to write  &lt;/p&gt;
&lt;h2&gt;
  
  
  What Fast Channels Delivers
&lt;/h2&gt;

&lt;p&gt;✅ &lt;strong&gt;Group messaging&lt;/strong&gt; - broadcast to chat rooms, user segments, notification groups&lt;br&gt;&lt;br&gt;
✅ &lt;strong&gt;Cross-process communication&lt;/strong&gt; - HTTP → WebSocket, background workers → clients&lt;br&gt;&lt;br&gt;
✅ &lt;strong&gt;Auto connection management&lt;/strong&gt; - join/leave groups automatically&lt;br&gt;&lt;br&gt;
✅ &lt;strong&gt;Multi-instance scaling&lt;/strong&gt; - Redis-backed message routing&lt;br&gt;&lt;br&gt;
✅ &lt;strong&gt;Built-in testing framework&lt;/strong&gt; - proper WebSocket test utilities&lt;br&gt;&lt;br&gt;
✅ &lt;strong&gt;Consumer patterns&lt;/strong&gt; - WebSocket handlers that feel like FastAPI views  &lt;/p&gt;
&lt;h2&gt;
  
  
  Battle-Tested in Production
&lt;/h2&gt;

&lt;p&gt;This isn't experimental tech. Django Channels has powered real-time features for &lt;strong&gt;millions of users&lt;/strong&gt; across thousands of production applications for years. Fast Channels brings that same proven architecture to FastAPI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Simple but powerful
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatConsumer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AsyncWebsocketConsumer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat_room&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# Auto-join on connect
&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text_data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Broadcast to all users in the room
&lt;/span&gt;        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channel_layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group_send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat_room&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat_message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text_data&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Production-Ready Features
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Redis Integration&lt;/strong&gt;: Full Redis support with Sentinel for high availability&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Multiple Backends&lt;/strong&gt;: In-memory for testing, Redis Queue for reliability, Redis Pub/Sub for speed&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Type Safety&lt;/strong&gt;: Complete mypy and pyright support&lt;br&gt;&lt;br&gt;
&lt;strong&gt;ASGI Universal&lt;/strong&gt;: Works with FastAPI, Starlette, Quart, any ASGI framework&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Testing Built-in&lt;/strong&gt;: Comprehensive WebSocket testing utilities  &lt;/p&gt;

&lt;h2&gt;
  
  
  FastAPI vs Fast Channels vs Alternatives
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Native FastAPI&lt;/th&gt;
&lt;th&gt;Broadcaster&lt;/th&gt;
&lt;th&gt;Fast Channels&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Basic WebSocket&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Group messaging&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consumer patterns&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message persistence&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testing framework&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto connection mgmt&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-process msgs&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production battle-tested&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Real-World Impact
&lt;/h2&gt;

&lt;p&gt;Teams are using Fast Channels for:&lt;/p&gt;

&lt;p&gt;🏪 &lt;strong&gt;E-commerce&lt;/strong&gt;: Live inventory, order tracking, flash sale updates&lt;br&gt;&lt;br&gt;
💬 &lt;strong&gt;Chat platforms&lt;/strong&gt;: Real-time messaging, typing indicators, presence&lt;br&gt;&lt;br&gt;
📊 &lt;strong&gt;Dashboards&lt;/strong&gt;: Live metrics, system monitoring, alerts&lt;br&gt;&lt;br&gt;
🎮 &lt;strong&gt;Gaming&lt;/strong&gt;: Multiplayer sync, leaderboards, real-time events&lt;br&gt;&lt;br&gt;
🔔 &lt;strong&gt;Notifications&lt;/strong&gt;: Push alerts, system updates, user notifications  &lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Start
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;fast-channels[redis]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register a channel layer, create a consumer, add to FastAPI - done. Your WebSockets now scale across multiple processes and servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Matters
&lt;/h2&gt;

&lt;p&gt;Real-time features shouldn't require reinventing messaging infrastructure. Fast Channels gives you Django's proven patterns with FastAPI's developer experience - the best of both worlds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production-ready&lt;/strong&gt;: Redis Sentinel support, proper error handling, connection cleanup&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Developer-friendly&lt;/strong&gt;: Type safety, testing utilities, clear documentation&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Scalable&lt;/strong&gt;: Multi-process, multi-server, battle-tested architecture  &lt;/p&gt;

&lt;p&gt;Stop building WebSocket boilerplate. Start building features.&lt;/p&gt;




&lt;p&gt;🔗 &lt;strong&gt;Links:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/huynguyengl99/fast-channels" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pypi.org/project/fast-channels/" rel="noopener noreferrer"&gt;PyPI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://fast-channels.readthedocs.io/" rel="noopener noreferrer"&gt;Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://fast-channels.readthedocs.io/en/latest/tutorial/index.html" rel="noopener noreferrer"&gt;Tutorial&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Building real-time features? What's your biggest WebSocket challenge? 👇&lt;/em&gt;&lt;/p&gt;

</description>
      <category>fastapi</category>
      <category>websocket</category>
    </item>
    <item>
      <title>Building a Modern, Type-Safe Authentication System for Django REST Framework</title>
      <dc:creator>Huy Nguyen</dc:creator>
      <pubDate>Wed, 09 Jul 2025 13:59:32 +0000</pubDate>
      <link>https://dev.to/huynguyengl99/building-a-modern-type-safe-authentication-system-for-django-rest-framework-c42</link>
      <guid>https://dev.to/huynguyengl99/building-a-modern-type-safe-authentication-system-for-django-rest-framework-c42</guid>
      <description>&lt;p&gt;Have you ever spent hours fighting with authentication setup in your Django REST API? Or struggled with type errors that could have been caught at development time? Or wished your API documentation would just... work automatically?&lt;/p&gt;

&lt;p&gt;I've been there. After years of wrestling with existing Django authentication packages, I decided to build something better: &lt;strong&gt;DRF Auth Kit&lt;/strong&gt; – a modern, type-safe authentication toolkit that just works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Authentication Shouldn't Be This Hard
&lt;/h2&gt;

&lt;p&gt;Let me paint a picture. You're building a modern web app with a Django REST API backend. You need:&lt;/p&gt;

&lt;p&gt;✅ JWT authentication with cookie support&lt;/p&gt;

&lt;p&gt;✅ Multi-factor authentication&lt;/p&gt;

&lt;p&gt;✅ Social login (Google, GitHub, etc.)&lt;/p&gt;

&lt;p&gt;✅ Automatic API documentation&lt;/p&gt;

&lt;p&gt;✅ Type safety throughout&lt;/p&gt;

&lt;p&gt;With existing solutions, you'd typically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install 3-4 different packages&lt;/li&gt;
&lt;li&gt;Write custom serializers and views&lt;/li&gt;
&lt;li&gt;Manually configure complex URL patterns&lt;/li&gt;
&lt;li&gt;Struggle with type hints and documentation&lt;/li&gt;
&lt;li&gt;Debug cryptic authentication errors&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;There had to be a better way.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: DRF Auth Kit
&lt;/h2&gt;

&lt;p&gt;DRF Auth Kit is what I wish existed when I started building Django APIs. Here's what makes it different:&lt;/p&gt;

&lt;h3&gt;
  
  
  🎯 One Package, Everything Included
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Get everything you need&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;drf-auth-kit[all]

&lt;span class="c"&gt;# Or pick what you need&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;drf-auth-kit[mfa]     &lt;span class="c"&gt;# + Multi-factor auth&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;drf-auth-kit[social]  &lt;span class="c"&gt;# + Social authentication&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🔒 Universal Authentication Backend
&lt;/h3&gt;

&lt;p&gt;Instead of juggling multiple authentication classes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# settings.py
&lt;/span&gt;&lt;span class="n"&gt;REST_FRAMEWORK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;DEFAULT_AUTHENTICATION_CLASSES&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auth_kit.authentication.AuthKitAuthentication&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;AUTH_KIT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AUTH_TYPE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;jwt&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# or 'token', 'custom'
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;USE_AUTH_COOKIE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;USE_MFA&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This single configuration handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JWT tokens with automatic refresh&lt;/li&gt;
&lt;li&gt;DRF token authentication&lt;/li&gt;
&lt;li&gt;Custom authentication backends&lt;/li&gt;
&lt;li&gt;HTTP-only cookies with security headers&lt;/li&gt;
&lt;li&gt;Multi-factor authentication&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🛠️ Intelligent URL Management
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Conditional URL patterns&lt;/strong&gt; - URLs automatically included/excluded based on settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# When USE_MFA=True, automatically creates:
# POST /api/auth/login/         # First step (password)
# POST /api/auth/login/verify/  # Second step (MFA code)
# POST /api/auth/login/change-method/
# POST /api/auth/login/resend/
# GET /api/auth/mfa/           # MFA management endpoints
&lt;/span&gt;
&lt;span class="c1"&gt;# When USE_MFA=False, creates:
# POST /api/auth/login/         # Direct login
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Social authentication&lt;/strong&gt; - automatic endpoint generation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Just add a provider to INSTALLED_APPS:
&lt;/span&gt;&lt;span class="n"&gt;INSTALLED_APPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;allauth.socialaccount.providers.google&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auth_kit.social&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Automatically creates:
# POST /api/auth/social/google/
# POST /api/auth/social/google/connect/
# GET /api/auth/social/accounts/
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  📝 Type Safety That Actually Works
&lt;/h3&gt;

&lt;p&gt;Every component includes comprehensive type hints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;auth_kit.authentication&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AuthKitAuthentication&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;auth_kit.serializers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LoginRequestSerializer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;auth_kit.views&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LoginView&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Full mypy and pyright compatibility&lt;/strong&gt; with comprehensive type hints throughout the codebase. Your IDE will thank you. No more guessing what parameters a function expects or what a setting does.&lt;/p&gt;

&lt;h3&gt;
  
  
  📚 API Documentation That Updates Itself
&lt;/h3&gt;

&lt;p&gt;Integration with DRF Spectacular means your API docs are always current:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Zero configuration needed
&lt;/span&gt;&lt;span class="n"&gt;INSTALLED_APPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;drf_spectacular&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auth_kit&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visit &lt;code&gt;/api/docs/&lt;/code&gt; and you'll see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Interactive Swagger UI&lt;/li&gt;
&lt;li&gt;Complete request/response schemas&lt;/li&gt;
&lt;li&gt;Authentication flow examples&lt;/li&gt;
&lt;li&gt;Working test interface&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpf0r3jm4h4v1fjvckcho.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpf0r3jm4h4v1fjvckcho.png" alt="API Documentation Example" width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  🛡️ MFA Made Simple
&lt;/h3&gt;

&lt;p&gt;Multi-factor authentication that doesn't require a PhD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Enable MFA
&lt;/span&gt;&lt;span class="n"&gt;INSTALLED_APPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auth_kit&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auth_kit.mfa&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# Just add this!
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;AUTH_KIT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;USE_MFA&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# URLs automatically reconfigure
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Automatic URL reconfiguration&lt;/strong&gt; when MFA is enabled:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Login endpoint becomes two-step flow&lt;/li&gt;
&lt;li&gt;MFA management endpoints automatically created&lt;/li&gt;
&lt;li&gt;All endpoints documented in your OpenAPI schema&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Email-based MFA with HTML/text templates&lt;/li&gt;
&lt;li&gt;Authenticator app support (Google Authenticator, Authy)&lt;/li&gt;
&lt;li&gt;Backup codes for account recovery&lt;/li&gt;
&lt;li&gt;Extensible system for custom MFA methods&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The login flow becomes a clean two-step process:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Username/password → Get ephemeral token&lt;/li&gt;
&lt;li&gt;MFA code → Get final auth tokens&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  🌐 Social Authentication That Just Works
&lt;/h3&gt;

&lt;p&gt;Social login with 50+ providers and &lt;strong&gt;automatic URL generation&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;INSTALLED_APPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auth_kit&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auth_kit.social&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# Automatic URL generation
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;allauth.socialaccount.providers.google&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;allauth.socialaccount.providers.github&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# Add any provider, URLs created automatically!
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# URLs are automatically created for each provider:
# POST /api/auth/social/google/
# POST /api/auth/social/github/
# POST /api/auth/social/google/connect/
# POST /api/auth/social/github/connect/
# GET /api/auth/social/accounts/
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;No manual URL configuration needed!&lt;/strong&gt; The system scans your installed providers and creates endpoints automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  🍪 Security-First Cookie Authentication
&lt;/h3&gt;

&lt;p&gt;HTTP-only cookies with all the security features:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;AUTH_KIT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;USE_AUTH_COOKIE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AUTH_COOKIE_SECURE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# HTTPS only
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AUTH_COOKIE_HTTPONLY&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;# No JavaScript access
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AUTH_COOKIE_SAMESITE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Lax&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# CSRF protection
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Perfect for modern web apps where you want security without complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Example: Complete Auth Setup
&lt;/h2&gt;

&lt;p&gt;Here's how you'd set up a complete authentication system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# settings.py
&lt;/span&gt;&lt;span class="n"&gt;INSTALLED_APPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rest_framework&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auth_kit&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auth_kit.mfa&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auth_kit.social&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;allauth&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;allauth.account&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;allauth.socialaccount&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;allauth.socialaccount.providers.google&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;REST_FRAMEWORK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;DEFAULT_AUTHENTICATION_CLASSES&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auth_kit.authentication.AuthKitAuthentication&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;AUTH_KIT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AUTH_TYPE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;jwt&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;USE_AUTH_COOKIE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;USE_MFA&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Social auth providers
&lt;/span&gt;&lt;span class="n"&gt;SOCIALACCOUNT_PROVIDERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;google&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SCOPE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;profile&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AUTH_PARAMS&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;access_type&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;online&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;OAUTH_PKCE_ENABLED&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;APP&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;your-google-client-id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;secret&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;your-google-client-secret&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# urls.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.urls&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;include&lt;/span&gt;

&lt;span class="n"&gt;urlpatterns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;api/auth/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auth_kit.urls&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;api/auth/social/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auth_kit.social.urls&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it! You now have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JWT authentication with cookies&lt;/li&gt;
&lt;li&gt;User registration and email verification&lt;/li&gt;
&lt;li&gt;Password reset functionality&lt;/li&gt;
&lt;li&gt;Multi-factor authentication&lt;/li&gt;
&lt;li&gt;Social login with Google&lt;/li&gt;
&lt;li&gt;Complete API documentation&lt;/li&gt;
&lt;li&gt;Type safety throughout&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Customization: Make It Yours
&lt;/h2&gt;

&lt;p&gt;Need custom behavior? Override any component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Custom login serializer
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CustomLoginSerializer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LoginRequestSerializer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;remember_me&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;serializers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BooleanField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;attrs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# Your custom logic here
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;

&lt;span class="c1"&gt;# Apply it
&lt;/span&gt;&lt;span class="n"&gt;AUTH_KIT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;LOGIN_REQUEST_SERIALIZER&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;myapp.serializers.CustomLoginSerializer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Want a custom MFA method? Easy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SMSMFAHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseMFAHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Your SMS implementation
&lt;/span&gt;        &lt;span class="nf"&gt;send_sms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your verification code: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Register it
&lt;/span&gt;&lt;span class="n"&gt;AUTH_KIT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;MFA_HANDLERS&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auth_kit.mfa.handlers.app.MFAAppHandler&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auth_kit.mfa.handlers.email.MFAEmailHandler&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;myapp.handlers.SMSMFAHandler&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Production-Ready Features
&lt;/h2&gt;

&lt;p&gt;DRF Auth Kit isn't just for prototypes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Comprehensive test coverage&lt;/strong&gt; (&amp;gt;95%) across multiple Python/Django versions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internationalization&lt;/strong&gt; support for 57 languages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security best practices&lt;/strong&gt; built-in&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance optimized&lt;/strong&gt; with efficient database queries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting&lt;/strong&gt; and &lt;strong&gt;audit logging&lt;/strong&gt; (coming soon)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Numbers Don't Lie
&lt;/h2&gt;

&lt;p&gt;Here's how DRF Auth Kit compares to existing solutions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;dj-rest-auth&lt;/th&gt;
&lt;th&gt;django-trench&lt;/th&gt;
&lt;th&gt;DRF Auth Kit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Type Safety&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ Full mypy/pyright&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto OpenAPI&lt;/td&gt;
&lt;td&gt;⚠️ Partial&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ Complete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookie Auth&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ Enhanced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MFA Support&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅ Simplified&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Social Auth&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ Better integration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Auth&lt;/td&gt;
&lt;td&gt;⚠️ Complex&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ Easy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;I18n Support&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ 57 languages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test Coverage&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;✅ &amp;gt;95%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Try It Today
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;drf-auth-kit[all]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check out the &lt;a href="https://drf-auth-kit.readthedocs.io/" rel="noopener noreferrer"&gt;complete documentation&lt;/a&gt; for detailed guides, examples, and API reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;The roadmap includes exciting features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WebAuthn support&lt;/strong&gt; for passwordless authentication&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardware security keys&lt;/strong&gt; (YubiKey, FIDO2)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting&lt;/strong&gt; and &lt;strong&gt;audit logging&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Advanced security features&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Join the Community
&lt;/h2&gt;

&lt;p&gt;DRF Auth Kit is open source and built by developers, for developers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/forthecraft/drf-auth-kit" rel="noopener noreferrer"&gt;github.com/forthecraft/drf-auth-kit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation&lt;/strong&gt;: &lt;a href="https://drf-auth-kit.readthedocs.io/" rel="noopener noreferrer"&gt;drf-auth-kit.readthedocs.io&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PyPI&lt;/strong&gt;: &lt;a href="https://pypi.org/project/drf-auth-kit/" rel="noopener noreferrer"&gt;pypi.org/project/drf-auth-kit&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Building authentication shouldn't be a painful experience. With DRF Auth Kit, you get a modern, type-safe solution that grows with your project – from simple JWT authentication to complex multi-factor flows.&lt;/p&gt;

&lt;p&gt;The combination of type safety, automatic documentation, and developer-friendly APIs makes it perfect for both new projects and existing ones looking to modernize their authentication.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What are you waiting for?&lt;/strong&gt; Give it a try in your next project and let me know what you think! 🚀&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>authentication</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Chanx: The Missing Toolkit for Django Channels - Production-Ready WebSockets Made Simple</title>
      <dc:creator>Huy Nguyen</dc:creator>
      <pubDate>Wed, 11 Jun 2025 08:13:18 +0000</pubDate>
      <link>https://dev.to/huynguyengl99/chanx-the-missing-toolkit-for-django-channels-production-ready-websockets-made-simple-27om</link>
      <guid>https://dev.to/huynguyengl99/chanx-the-missing-toolkit-for-django-channels-production-ready-websockets-made-simple-27om</guid>
      <description>&lt;p&gt;&lt;em&gt;Building WebSocket applications with Django Channels just got a whole lot easier&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with Django Channels
&lt;/h2&gt;

&lt;p&gt;Don't get me wrong - Django Channels is fantastic for adding WebSocket support to Django applications. But after building several real-time apps, I kept running into the same challenges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Setting up authentication for WebSocket connections&lt;/li&gt;
&lt;li&gt;Validating message structures and preventing runtime errors&lt;/li&gt;
&lt;li&gt;Testing multi-user WebSocket interactions&lt;/li&gt;
&lt;li&gt;Managing group messaging patterns&lt;/li&gt;
&lt;li&gt;Debugging WebSocket connections during development&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I found myself writing the same boilerplate code over and over. So I built &lt;strong&gt;Chanx&lt;/strong&gt; to solve these problems once and for all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Chanx?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/huynguyengl99/chanx" rel="noopener noreferrer"&gt;Chanx&lt;/a&gt; is a comprehensive toolkit that extends Django Channels with production-ready features. Think of it as what Django REST Framework is to Django views - but for WebSocket consumers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Features That'll Save You Hours
&lt;/h2&gt;

&lt;h3&gt;
  
  
  🔐 DRF-Style Authentication
&lt;/h3&gt;

&lt;p&gt;Use your existing authentication classes with WebSockets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chanx.generic.websocket&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncJsonWebsocketConsumer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework.authentication&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SessionAuthentication&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework.permissions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;IsAuthenticated&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatConsumer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AsyncJsonWebsocketConsumer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="n"&gt;authentication_classes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;SessionAuthentication&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;permission_classes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;IsAuthenticated&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;receive_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Only authenticated users reach here
&lt;/span&gt;        &lt;span class="c1"&gt;# self.user is automatically populated
&lt;/span&gt;        &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  📝 Type-Safe Messaging with Pydantic
&lt;/h3&gt;

&lt;p&gt;No more runtime errors from malformed messages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chanx.messages.base&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseMessage&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseMessage&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NotificationMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseMessage&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notify&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notify&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;

&lt;span class="c1"&gt;# Union type for type safety
&lt;/span&gt;&lt;span class="n"&gt;AllMessages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ChatMessage&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;NotificationMessage&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MyConsumer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AsyncJsonWebsocketConsumer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AllMessages&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;receive_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AllMessages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Pattern matching with full type safety
&lt;/span&gt;        &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handle_chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;NotificationMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handle_notification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🎮 WebSocket Playground
&lt;/h3&gt;

&lt;p&gt;Like Swagger, but for WebSockets! Auto-discovers your endpoints and lets you test them interactively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# settings.py
&lt;/span&gt;&lt;span class="n"&gt;INSTALLED_APPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="c1"&gt;# ...
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;chanx.playground&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# urls.py
&lt;/span&gt;&lt;span class="n"&gt;urlpatterns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;playground/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;chanx.playground.urls&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visit &lt;code&gt;/playground/websocket/&lt;/code&gt; and test your WebSocket endpoints without writing JavaScript!&lt;/p&gt;

&lt;h3&gt;
  
  
  👥 Simplified Group Messaging
&lt;/h3&gt;

&lt;p&gt;Pub/sub messaging made easy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatConsumer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AsyncJsonWebsocketConsumer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_groups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;room_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url_route&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kwargs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;room_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat_room_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;room_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;receive_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Broadcast to all users in the room
&lt;/span&gt;        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_group_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Messages automatically include metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"chat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hello everyone!"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"is_mine"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"is_current"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🧪 Testing That Actually Works
&lt;/h3&gt;

&lt;p&gt;Multi-user WebSocket testing without the headaches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chanx.testing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;WebsocketTestCase&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WebsocketTestCase&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ws_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/ws/chat/room1/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_multi_user_chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# First user (automatic setup)
&lt;/span&gt;        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auth_communicator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auth_communicator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assert_authenticated_status_ok&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="c1"&gt;# Second user with different credentials
&lt;/span&gt;        &lt;span class="n"&gt;user2_comm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_communicator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user2_headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;user2_comm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="c1"&gt;# Test group messaging
&lt;/span&gt;        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auth_communicator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;responses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;user2_comm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive_all_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wait_group&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_mine&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Real-World Example: Building a Chat App
&lt;/h2&gt;

&lt;p&gt;Here's how simple it is to build a production-ready chat application:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Define Your Messages
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chanx.messages.base&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BaseGroupMessage&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseMessage&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserJoinedMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseGroupMessage&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_joined&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_joined&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;

&lt;span class="n"&gt;ChatMessages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ChatMessage&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;PingMessage&lt;/span&gt;  &lt;span class="c1"&gt;# Include ping for health checks
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Create Your Consumer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chanx.generic.websocket&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncJsonWebsocketConsumer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chanx.messages.incoming&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PingMessage&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chanx.messages.outgoing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PongMessage&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatConsumer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AsyncJsonWebsocketConsumer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ChatMessages&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="n"&gt;authentication_classes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;SessionAuthentication&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;permission_classes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;IsAuthenticated&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_groups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;room_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url_route&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kwargs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;room_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat_room_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;room_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post_authentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Called after successful authentication&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_group_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;UserJoinedMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;receive_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ChatMessages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;PingMessage&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PongMessage&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="c1"&gt;# Save to database and broadcast
&lt;/span&gt;                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_group_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Set Up Routing
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# chat/routing.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chanx.routing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;channels.routing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;URLRouter&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;URLRouter&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;int:room_id&amp;gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChatConsumer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;as_asgi&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;# project/routing.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chanx.routing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;include&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;channels.routing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;URLRouter&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;URLRouter&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ws/chat/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat.routing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Test It
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatRoomTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WebsocketTestCase&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ws_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/ws/chat/123/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_user_can_join_and_chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auth_communicator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auth_communicator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assert_authenticated_status_ok&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="c1"&gt;# Should receive user_joined message
&lt;/span&gt;        &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auth_communicator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive_all_json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_joined&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Send a chat message
&lt;/span&gt;        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auth_communicator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;responses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auth_communicator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive_all_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wait_group&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why Developers Love Chanx
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Complete Type Safety
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Generic type parameters for compile-time checking
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MyConsumer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AsyncJsonWebsocketConsumer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;IncomingMessages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# Required: Your message types
&lt;/span&gt;    &lt;span class="n"&gt;ChannelEvents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;# Optional: Channel layer events
&lt;/span&gt;    &lt;span class="n"&gt;Room&lt;/span&gt;              &lt;span class="c1"&gt;# Optional: Model for object permissions
&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="n"&gt;queryset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# For object-level permissions
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Enhanced Routing
&lt;/h3&gt;

&lt;p&gt;Django-style routing specifically designed for WebSockets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chanx.routing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;include&lt;/span&gt;

&lt;span class="c1"&gt;# Use familiar Django patterns
&lt;/span&gt;&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;URLRouter&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;room/&amp;lt;str:room_name&amp;gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChatConsumer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;as_asgi&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="nf"&gt;re_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^admin/(?P&amp;lt;id&amp;gt;\d+)/$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AdminConsumer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;as_asgi&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api.routing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Channel Events
&lt;/h3&gt;

&lt;p&gt;Type-safe communication between consumers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Send events from views, tasks, or other consumers
&lt;/span&gt;&lt;span class="n"&gt;ChatConsumer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_channel_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat_room_123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;NotificationEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;System update&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Handle in consumer
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;receive_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;NotificationEvent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;NotificationEvent&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SystemMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;chanx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Essential links:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📚 &lt;a href="https://chanx.readthedocs.io/" rel="noopener noreferrer"&gt;Documentation&lt;/a&gt; - Comprehensive guides and examples&lt;/li&gt;
&lt;li&gt;💻 &lt;a href="https://github.com/huynguyengl99/chanx" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; - Source code and issues&lt;/li&gt;
&lt;li&gt;🚀 &lt;a href="https://github.com/huynguyengl99/chanx-example" rel="noopener noreferrer"&gt;Complete Example&lt;/a&gt; - Production-ready chat app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Quick setup:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# settings.py
&lt;/span&gt;&lt;span class="n"&gt;INSTALLED_APPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;channels&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rest_framework&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;chanx.playground&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# For WebSocket testing UI
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;CHANX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SEND_COMPLETION&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# Important for testing
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SEND_AUTHENTICATION_MESSAGE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Perfect for AI Applications
&lt;/h2&gt;

&lt;p&gt;Chanx is especially powerful for AI chatbots and streaming applications:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_ai_chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Stream AI response token by token&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_ai_stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AIStreamingMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Community and Feedback
&lt;/h2&gt;

&lt;p&gt;Chanx is actively maintained and used in production applications. The community is growing, and I'm always looking for feedback:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What challenges are you facing with Django Channels?&lt;/li&gt;
&lt;li&gt;Which features would be most valuable to you?&lt;/li&gt;
&lt;li&gt;How can we make WebSocket development even easier?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Django Channels is powerful, but building production WebSocket applications shouldn't require reinventing the wheel every time. Chanx provides the missing pieces:&lt;/p&gt;

&lt;p&gt;✅ &lt;strong&gt;Authentication&lt;/strong&gt; - DRF integration out of the box&lt;br&gt;&lt;br&gt;
✅ &lt;strong&gt;Type Safety&lt;/strong&gt; - Catch errors at development time&lt;br&gt;&lt;br&gt;
✅ &lt;strong&gt;Testing&lt;/strong&gt; - Multi-user scenarios made simple&lt;br&gt;&lt;br&gt;
✅ &lt;strong&gt;Developer Tools&lt;/strong&gt; - WebSocket playground for debugging&lt;br&gt;&lt;br&gt;
✅ &lt;strong&gt;Group Messaging&lt;/strong&gt; - Pub/sub patterns simplified&lt;br&gt;&lt;br&gt;
✅ &lt;strong&gt;Production Ready&lt;/strong&gt; - Battle-tested in real applications  &lt;/p&gt;

&lt;p&gt;Whether you're building a chat application, real-time collaboration tool, or AI-powered interface, Chanx helps you ship faster with fewer bugs.&lt;/p&gt;

&lt;p&gt;Try it out and let me know what you think! 🚀&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What's your experience with Django Channels? Have you run into similar challenges? Share your thoughts in the comments!&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>websockets</category>
      <category>python</category>
      <category>chatbot</category>
    </item>
    <item>
      <title>🚀 Streamline Your Python &amp; Django Development with Production-Ready Cookiecutter Templates</title>
      <dc:creator>Huy Nguyen</dc:creator>
      <pubDate>Mon, 09 Jun 2025 15:18:53 +0000</pubDate>
      <link>https://dev.to/huynguyengl99/streamline-your-python-django-development-with-production-ready-cookiecutter-templates-455o</link>
      <guid>https://dev.to/huynguyengl99/streamline-your-python-django-development-with-production-ready-cookiecutter-templates-455o</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: I've created a comprehensive cookiecutter template collection that generates production-ready Python apps, PyPI packages, Django REST APIs, and DRF packages with modern tooling, best practices, and zero configuration hassle.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: Starting Projects Shouldn't Be This Hard
&lt;/h2&gt;

&lt;p&gt;How many times have you started a new Python project and spent hours setting up the same boilerplate code? Configuring &lt;code&gt;pyproject.toml&lt;/code&gt;, setting up pre-commit hooks, writing GitHub Actions, configuring Docker, setting up Django with all the modern best practices... &lt;/p&gt;

&lt;p&gt;The list goes on. And then you realize you forgot to configure type checking, or your test coverage setup is broken, or you're missing that one crucial piece of tooling that makes development smooth.&lt;/p&gt;

&lt;p&gt;I've been there. We've all been there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: One Template to Rule Them All (Actually, Four)
&lt;/h2&gt;

&lt;p&gt;That's why I created &lt;strong&gt;&lt;a href="https://github.com/huynguyengl99/cookiecutter-python" rel="noopener noreferrer"&gt;cookiecutter-python&lt;/a&gt;&lt;/strong&gt; — a curated collection of four specialized cookiecutter templates that cover the most common Python development scenarios:&lt;/p&gt;

&lt;h3&gt;
  
  
  🐍 &lt;strong&gt;Python App&lt;/strong&gt; - For General-Purpose Applications
&lt;/h3&gt;

&lt;p&gt;Perfect for CLI tools, data processing scripts, or any standalone Python application.&lt;/p&gt;

&lt;h3&gt;
  
  
  📦 &lt;strong&gt;Python Package&lt;/strong&gt; - For PyPI Publishing
&lt;/h3&gt;

&lt;p&gt;Ready-to-publish package structure with documentation, CI/CD, and multiple license options.&lt;/p&gt;

&lt;h3&gt;
  
  
  🌐 &lt;strong&gt;Django REST App&lt;/strong&gt; - For Full-Stack Web APIs
&lt;/h3&gt;

&lt;p&gt;Complete Django REST Framework setup with authentication, database, optional Celery/WebSockets, and cloud deployment support.&lt;/p&gt;

&lt;h3&gt;
  
  
  🧩 &lt;strong&gt;DRF Package&lt;/strong&gt; - For Reusable Django Components
&lt;/h3&gt;

&lt;p&gt;Lightweight structure for building and distributing Django REST Framework extensions.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Makes These Templates Special?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ✨ &lt;strong&gt;Modern Tooling Out of the Box&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Each template is carefully configured with specialized tooling for its use case:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🔧 Universal Tools (All Templates):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/astral-sh/uv" rel="noopener noreferrer"&gt;UV&lt;/a&gt;&lt;/strong&gt; for lightning-fast dependency management with intelligent dependency groups&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/astral-sh/ruff" rel="noopener noreferrer"&gt;Ruff&lt;/a&gt;&lt;/strong&gt; for blazing-fast linting and formatting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/psf/black" rel="noopener noreferrer"&gt;Black&lt;/a&gt;&lt;/strong&gt; for consistent code formatting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dual type checking&lt;/strong&gt; with both &lt;strong&gt;&lt;a href="https://mypy.readthedocs.io/" rel="noopener noreferrer"&gt;mypy&lt;/a&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;a href="https://github.com/microsoft/pyright" rel="noopener noreferrer"&gt;pyright&lt;/a&gt;&lt;/strong&gt; for maximum type safety&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-commit hooks&lt;/strong&gt; for automated quality checks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pytest&lt;/strong&gt; with parallel execution (&lt;code&gt;pytest-xdist&lt;/code&gt;) and coverage reporting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions&lt;/strong&gt; CI/CD pipelines&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;📦 Package Templates (PyPackage &amp;amp; DRF Package) Add:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://commitizen-tools.github.io/commitizen/" rel="noopener noreferrer"&gt;Commitizen&lt;/a&gt;&lt;/strong&gt; for conventional commits and automated versioning&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://interrogate.readthedocs.io/" rel="noopener noreferrer"&gt;Interrogate&lt;/a&gt;&lt;/strong&gt; for docstring coverage monitoring with badge generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.sphinx-doc.org/" rel="noopener noreferrer"&gt;Sphinx&lt;/a&gt;&lt;/strong&gt; ecosystem for professional documentation (myst-parser, ReadTheDocs theme)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated publishing&lt;/strong&gt; workflows to PyPI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;🌐 Django Templates (DRF &amp;amp; DRF Package) Add:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://factoryboy.readthedocs.io/" rel="noopener noreferrer"&gt;Factory Boy&lt;/a&gt;&lt;/strong&gt; for realistic test data generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://faker.readthedocs.io/" rel="noopener noreferrer"&gt;Faker&lt;/a&gt;&lt;/strong&gt; for generating fake data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/typeddjango/django-stubs" rel="noopener noreferrer"&gt;django-stubs&lt;/a&gt;&lt;/strong&gt; + &lt;strong&gt;&lt;a href="https://github.com/typeddjango/djangorestframework-stubs" rel="noopener noreferrer"&gt;djangorestframework-stubs&lt;/a&gt;&lt;/strong&gt; for comprehensive Django type checking&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://django-debug-toolbar.readthedocs.io/" rel="noopener noreferrer"&gt;django-debug-toolbar&lt;/a&gt;&lt;/strong&gt; for development debugging&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://ipython.org/" rel="noopener noreferrer"&gt;IPython&lt;/a&gt;&lt;/strong&gt; for enhanced Django shell experience&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/spulec/freezegun" rel="noopener noreferrer"&gt;Freezegun&lt;/a&gt;&lt;/strong&gt; for time-based testing&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🏗️ &lt;strong&gt;Production-Ready Architecture&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The Django templates include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL&lt;/strong&gt; integration with connection pooling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis&lt;/strong&gt; support for Celery and WebSockets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWT authentication&lt;/strong&gt; with django-rest-auth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API documentation&lt;/strong&gt; with Swagger/ReDoc&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker&lt;/strong&gt; containerization&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud storage&lt;/strong&gt; support (AWS S3, GCP, Azure)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email providers&lt;/strong&gt; integration (SendGrid, Mailgun, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🎯 &lt;strong&gt;Zero Configuration Philosophy&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Each generated project is immediately runnable. No additional setup required. Just generate, install dependencies, and start developing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Start Guide
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;p&gt;First, you'll need to install cookiecutter. I recommend using Homebrew (macOS) or pipx for a clean installation:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;macOS (Recommended):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;cookiecutter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Linux/Windows with pipx:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;cookiecutter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Alternative (with pip):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--user&lt;/span&gt; cookiecutter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why not just &lt;code&gt;pip install cookiecutter&lt;/code&gt;?&lt;/strong&gt; Since we'll be using UV for Python dependency management in the generated projects, keeping cookiecutter isolated prevents potential conflicts and keeps your global Python environment clean.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Generate Your Project
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cookiecutter gh:huynguyengl99/cookiecutter-python
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll be prompted to select your template type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Select a template
1 - Python app (A cookiecutter template for a normal python app)
2 - Python package (A cookiecutter template for a python package)  
3 - DRF (A cookiecutter template for a django rest framework app)
4 - DRF package (A cookiecutter template for a drf package)
Choose from [1/2/3/4] (1):
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Start Developing Immediately
&lt;/h3&gt;

&lt;p&gt;For a &lt;strong&gt;Django REST App&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;your-project
uv &lt;span class="nb"&gt;sync&lt;/span&gt;                    &lt;span class="c"&gt;# Install dependencies&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;       &lt;span class="c"&gt;# Start PostgreSQL/Redis&lt;/span&gt;
scripts/manage.sh migrate  &lt;span class="c"&gt;# Run migrations&lt;/span&gt;
scripts/manage.sh runserver &lt;span class="c"&gt;# Start development server&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your API documentation is immediately available at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Swagger UI: &lt;code&gt;http://localhost:8000/api/schema/swg/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;ReDoc: &lt;code&gt;http://localhost:8000/api/schema/redoc/&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Deep Dive: What Each Template Provides
&lt;/h2&gt;

&lt;h3&gt;
  
  
  🐍 Python App Template
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Perfect for&lt;/strong&gt;: CLI tools, data processing, automation scripts&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Includes&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Modern &lt;code&gt;pyproject.toml&lt;/code&gt; configuration&lt;/li&gt;
&lt;li&gt;Docker setup for easy deployment&lt;/li&gt;
&lt;li&gt;Comprehensive testing framework&lt;/li&gt;
&lt;li&gt;Code quality tools (Ruff, Black, mypy, pyright)&lt;/li&gt;
&lt;li&gt;GitHub Actions for CI/CD&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Generated Structure&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my-app/
├── pyproject.toml
├── docker-compose.yml
├── Dockerfile
├── scripts/
│   └── lint.sh
└── my_app/
    └── __init__.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  📦 Python Package Template
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Perfect for&lt;/strong&gt;: Open-source libraries, reusable components&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Includes&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PyPI-ready project structure&lt;/li&gt;
&lt;li&gt;Sphinx documentation with ReadTheDocs integration&lt;/li&gt;
&lt;li&gt;Multiple license options (MIT, BSD, Apache, GPL)&lt;/li&gt;
&lt;li&gt;Automated versioning with Commitizen&lt;/li&gt;
&lt;li&gt;Publishing workflow to PyPI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Generated Structure&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my-package/
├── pyproject.toml
├── LICENSE
├── docs/
├── tests/
├── .github/workflows/
└── my_package/
    └── __init__.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🌐 Django REST App Template
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Perfect for&lt;/strong&gt;: Web APIs, SaaS backends, microservices&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configuration Options&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Database&lt;/strong&gt;: PostgreSQL with multiple version options&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication&lt;/strong&gt;: JWT with optional Google OAuth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background Tasks&lt;/strong&gt;: Optional Celery integration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time&lt;/strong&gt;: Optional WebSocket support with Channels&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud Storage&lt;/strong&gt;: AWS S3, Google Cloud, Azure support&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email&lt;/strong&gt;: 10+ provider integrations (SendGrid, Mailgun, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API Style&lt;/strong&gt;: Optional camelCase conversion&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Generated Structure&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my-api/
├── pyproject.toml
├── docker-compose.yml
├── Dockerfile
├── scripts/
│   └── manage.sh
└── my_api/
    ├── config/
    │   ├── settings/
    │   ├── urls.py
    │   └── wsgi.py
    ├── accounts/        # Custom user model
    ├── core/           # Utilities
    └── status/         # Health check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key Features&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Custom User Model&lt;/strong&gt; with email authentication&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWT Authentication&lt;/strong&gt; with refresh token rotation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API Versioning&lt;/strong&gt; ready structure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comprehensive Testing&lt;/strong&gt; with factories and fixtures&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin Interface&lt;/strong&gt; customization&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured Logging&lt;/strong&gt; with structured logs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health Checks&lt;/strong&gt; and monitoring endpoints&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🧩 DRF Package Template
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Perfect for&lt;/strong&gt;: Django REST Framework extensions, reusable API components&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Includes&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sandbox Django project for testing&lt;/li&gt;
&lt;li&gt;Documentation with Django integration&lt;/li&gt;
&lt;li&gt;Package publishing to PyPI&lt;/li&gt;
&lt;li&gt;Testing across multiple Django/DRF versions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Unique Feature&lt;/strong&gt;: The sandbox directory serves dual purposes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Testing Environment&lt;/strong&gt;: Run tests against your package&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Development Playground&lt;/strong&gt;: Manual testing with a real Django app&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Advanced Features That Set These Apart
&lt;/h2&gt;

&lt;h3&gt;
  
  
  🔧 &lt;strong&gt;Intelligent Dependency Management with UV&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Each template uses UV's dependency groups for optimal development experience:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[dependency-groups]&lt;/span&gt;
&lt;span class="py"&gt;dev&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"commitizen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"factory-boy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"pytest-django"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c"&gt;# Core development&lt;/span&gt;
&lt;span class="py"&gt;lint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"black"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ruff"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"mypy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"pyright"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;           &lt;span class="c"&gt;# Code quality  &lt;/span&gt;
&lt;span class="py"&gt;test&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"pytest-cov"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"pytest-xdist"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"coverage"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="c"&gt;# Testing tools&lt;/span&gt;
&lt;span class="py"&gt;docs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"sphinx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"myst-parser"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"interrogate"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;      &lt;span class="c"&gt;# Documentation&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Smart defaults&lt;/strong&gt; with &lt;code&gt;uv sync&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Application templates&lt;/strong&gt;: Auto-installs &lt;code&gt;dev&lt;/code&gt; + &lt;code&gt;lint&lt;/code&gt; groups&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Package templates&lt;/strong&gt;: Auto-installs all groups for comprehensive development&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Selective installation&lt;/strong&gt;: &lt;code&gt;uv sync --group test&lt;/code&gt; for specific workflows&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🛡️ &lt;strong&gt;Type Safety First&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Every template includes comprehensive type checking with dual tools for maximum coverage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://mypy.readthedocs.io/" rel="noopener noreferrer"&gt;mypy&lt;/a&gt;&lt;/strong&gt; with specialized Django and DRF plugins for framework-specific type checking&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/microsoft/pyright" rel="noopener noreferrer"&gt;pyright&lt;/a&gt;&lt;/strong&gt; for advanced static analysis and fast feedback&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/typeddjango/django-stubs" rel="noopener noreferrer"&gt;django-stubs&lt;/a&gt;&lt;/strong&gt; for comprehensive Django typing (Django templates)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This dual approach catches different categories of type issues and provides the most robust type safety available in the Python ecosystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  📊 &lt;strong&gt;Quality Metrics &amp;amp; Automation&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Package templates&lt;/strong&gt; include sophisticated quality monitoring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Docstring coverage with automatic badge generation&lt;/span&gt;
interrogate &lt;span class="nt"&gt;--generate-badge&lt;/span&gt; docs/_static/ my_package

&lt;span class="c"&gt;# Conventional commits with automated versioning&lt;/span&gt;
cz commit  &lt;span class="c"&gt;# Interactive commit creation&lt;/span&gt;
cz bump    &lt;span class="c"&gt;# Automatic version bumping and changelog&lt;/span&gt;

&lt;span class="c"&gt;# Parallel testing for faster feedback&lt;/span&gt;
pytest &lt;span class="nt"&gt;-n5&lt;/span&gt;  &lt;span class="c"&gt;# Runs tests across 5 parallel processes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Quality gates enforce standards:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;80% docstring coverage&lt;/strong&gt; requirement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;100% type hint coverage&lt;/strong&gt; with mypy strict mode&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero linting violations&lt;/strong&gt; with Ruff and Black&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conventional commit format&lt;/strong&gt; for automated changelog generation&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ⚡ &lt;strong&gt;Modern Python Practices&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PEP 621&lt;/strong&gt; compliant &lt;code&gt;pyproject.toml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UV&lt;/strong&gt; for dependency management (10x faster than pip)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ruff&lt;/strong&gt; for linting (100x faster than flake8)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python 3.10+&lt;/strong&gt; with modern syntax support&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🚀 &lt;strong&gt;Production Deployment Ready&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Django templates include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-stage Docker builds&lt;/strong&gt; for optimized images&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gunicorn&lt;/strong&gt; configuration for production WSGI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment-based settings&lt;/strong&gt; (dev/test/production)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static file handling&lt;/strong&gt; with WhiteNoise&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database connection pooling&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Real-World Usage Examples
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Scenario 1: Building a SaaS API
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cookiecutter gh:huynguyengl99/cookiecutter-python
&lt;span class="c"&gt;# Select: 3 - DRF&lt;/span&gt;
&lt;span class="c"&gt;# Configure: PostgreSQL, JWT auth, Celery, SendGrid emails, AWS S3&lt;/span&gt;

&lt;span class="nb"&gt;cd &lt;/span&gt;my-saas-api
uv &lt;span class="nb"&gt;sync
&lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
scripts/manage.sh migrate
scripts/manage.sh createsuperuser
scripts/manage.sh runserver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result&lt;/strong&gt;: Full-featured API with user authentication, background tasks, email integration, and cloud storage. Ready for production deployment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 2: Publishing a Python Library
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cookiecutter gh:huynguyengl99/cookiecutter-python
&lt;span class="c"&gt;# Select: 2 - Python package&lt;/span&gt;
&lt;span class="c"&gt;# Configure: MIT license, GitHub username, PyPI publishing&lt;/span&gt;

&lt;span class="nb"&gt;cd &lt;/span&gt;my-library
uv &lt;span class="nb"&gt;sync
&lt;/span&gt;pytest  &lt;span class="c"&gt;# Tests pass immediately&lt;/span&gt;
scripts/lint.sh  &lt;span class="c"&gt;# Code quality checks pass&lt;/span&gt;
cz commit  &lt;span class="c"&gt;# Conventional commits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result&lt;/strong&gt;: Professional package structure ready for PyPI publishing with documentation, CI/CD, and automated releases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 3: Building a DRF Extension
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cookiecutter gh:huynguyengl99/cookiecutter-python
&lt;span class="c"&gt;# Select: 4 - DRF package&lt;/span&gt;

&lt;span class="nb"&gt;cd &lt;/span&gt;my-drf-extension
uv &lt;span class="nb"&gt;sync
&lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;  &lt;span class="c"&gt;# Test database&lt;/span&gt;
pytest sandbox  &lt;span class="c"&gt;# Test against real Django app&lt;/span&gt;
python sandbox/manage.py runserver  &lt;span class="c"&gt;# Manual testing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result&lt;/strong&gt;: Professional DRF package with testing sandbox and publishing workflow.&lt;/p&gt;




&lt;h2&gt;
  
  
  Best Practices Baked In
&lt;/h2&gt;

&lt;h3&gt;
  
  
  🧪 &lt;strong&gt;Testing Excellence&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Each template includes sophisticated testing infrastructure:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🏃‍♂️ Performance-Optimized Testing:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parallel execution&lt;/strong&gt; with &lt;code&gt;pytest-xdist&lt;/code&gt; (5 parallel processes by default)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smart test discovery&lt;/strong&gt; with proper Django integration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coverage reporting&lt;/strong&gt; with branch coverage and XML output for CI integration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;🏭 Test Data Generation (Django Templates):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Factory Boy integration for realistic test data
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;accounts.factories&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;UserFactory&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_user_creation&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;UserFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_active&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;⏰ Time-Based Testing:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Freezegun for testing time-dependent code
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;freezegun&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;freeze_time&lt;/span&gt;

&lt;span class="nd"&gt;@freeze_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2023-01-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_timestamp_behavior&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="c1"&gt;# Test behavior at specific time
&lt;/span&gt;    &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;🔧 Advanced Testing Features:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pytest-mock&lt;/strong&gt; for sophisticated mocking&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pytest-django&lt;/strong&gt; for Django-specific fixtures and database handling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Factory Boy&lt;/strong&gt; with custom factory metaclass for type-safe model factories&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coverage exclusions&lt;/strong&gt; for migrations, settings, and other non-testable code&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  📝 &lt;strong&gt;Documentation Standards&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Package templates include professional documentation tooling:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📖 Sphinx Documentation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automatic API documentation&lt;/strong&gt; generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ReadTheDocs&lt;/strong&gt; integration with automated builds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MyST parser&lt;/strong&gt; for Markdown support in reStructuredText projects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-references&lt;/strong&gt; and &lt;strong&gt;intersphinx&lt;/strong&gt; linking&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;📊 Documentation Quality Monitoring:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://interrogate.readthedocs.io/" rel="noopener noreferrer"&gt;Interrogate&lt;/a&gt;&lt;/strong&gt; for docstring coverage monitoring&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic badge generation&lt;/strong&gt; showing documentation coverage percentage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;80% coverage requirement&lt;/strong&gt; enforced in CI/CD&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SVG badges&lt;/strong&gt; integrated into documentation and README
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check docstring coverage&lt;/span&gt;
interrogate &lt;span class="nt"&gt;-vv&lt;/span&gt; my_package

&lt;span class="c"&gt;# Generate coverage badge&lt;/span&gt;
interrogate &lt;span class="nt"&gt;--generate-badge&lt;/span&gt; docs/_static/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;📋 API Documentation (Django Templates):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Swagger UI&lt;/strong&gt; and &lt;strong&gt;ReDoc&lt;/strong&gt; auto-generation from code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAPI 3.0&lt;/strong&gt; specification export&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interactive API testing&lt;/strong&gt; directly in documentation&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🔄 &lt;strong&gt;CI/CD Pipeline&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;GitHub Actions workflows include:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🧪 Comprehensive Testing:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-version testing&lt;/strong&gt; across Python 3.10-3.13&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel test execution&lt;/strong&gt; with &lt;code&gt;pytest-xdist&lt;/code&gt; for faster feedback&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coverage reporting&lt;/strong&gt; with Codecov integration and XML output&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matrix testing&lt;/strong&gt; across multiple dependency versions (Django templates)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;✅ Code Quality Gates:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Linting pipeline&lt;/strong&gt; with Ruff for import sorting and style checks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dual type checking&lt;/strong&gt; with both mypy and pyright&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code formatting&lt;/strong&gt; verification with Black&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TOML file validation&lt;/strong&gt; and sorting with toml-sort&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;📦 Automated Publishing (Package Templates):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Semantic versioning&lt;/strong&gt; with Commitizen integration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated PyPI publishing&lt;/strong&gt; on tagged releases&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub releases&lt;/strong&gt; with automated changelog generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build artifact&lt;/strong&gt; caching for faster CI runs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;🔒 Security &amp;amp; Quality:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dependency vulnerability scanning&lt;/strong&gt; &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-commit hook validation&lt;/strong&gt; in CI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build reproducibility&lt;/strong&gt; with UV lock files&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🎨 &lt;strong&gt;Code Quality&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Pre-commit hooks automatically run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ruff&lt;/strong&gt; for linting and import sorting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Black&lt;/strong&gt; for code formatting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;toml-sort&lt;/strong&gt; for TOML file organization&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type checking&lt;/strong&gt; with mypy/pyright&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Common Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Q: Why four separate templates instead of one configurable template?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A&lt;/strong&gt;: Each template is optimized for its specific use case. A Python package has different needs than a Django API. Separate templates mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cleaner code&lt;/strong&gt; without excessive conditionals&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster generation&lt;/strong&gt; with only relevant files&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better maintenance&lt;/strong&gt; with focused concerns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Easier customization&lt;/strong&gt; for specific needs&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Q: Why UV instead of pip/poetry?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A&lt;/strong&gt;: UV is incredibly fast (10-100x faster than pip) and handles both Python version management and dependency resolution. It's the future of Python package management.&lt;/p&gt;

&lt;h3&gt;
  
  
  Q: Can I customize the generated templates?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A&lt;/strong&gt;: Absolutely! The templates are designed to be starting points. All configuration is in standard files (&lt;code&gt;pyproject.toml&lt;/code&gt;, &lt;code&gt;settings.py&lt;/code&gt;, etc.) that you can modify as needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Q: What about database migrations in Django templates?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A&lt;/strong&gt;: The Django templates include a custom user model and initial migrations. After generation, run &lt;code&gt;scripts/manage.sh migrate&lt;/code&gt; and you're ready to go.&lt;/p&gt;




&lt;h2&gt;
  
  
  Future Roadmap
&lt;/h2&gt;

&lt;p&gt;I'm continuously improving these templates based on community feedback:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI template&lt;/strong&gt; for modern async APIs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js + Django&lt;/strong&gt; full-stack template&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes deployment&lt;/strong&gt; configurations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More cloud provider&lt;/strong&gt; integrations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Additional authentication&lt;/strong&gt; providers&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Getting Started Today
&lt;/h2&gt;

&lt;p&gt;Ready to streamline your Python development? Here's how to get started:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Install cookiecutter&lt;/strong&gt;: &lt;code&gt;pip install cookiecutter&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate your project&lt;/strong&gt;: &lt;code&gt;cookiecutter gh:huynguyengl99/cookiecutter-python&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose your template&lt;/strong&gt; and configure options&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start developing&lt;/strong&gt; immediately!&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Useful Links
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;📂 Repository&lt;/strong&gt;: &lt;a href="https://github.com/huynguyengl99/cookiecutter-python" rel="noopener noreferrer"&gt;github.com/huynguyengl99/cookiecutter-python&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;📖 Documentation&lt;/strong&gt;: Full README with detailed setup instructions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;🐛 Issues&lt;/strong&gt;: Report bugs or request features&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;💡 Discussions&lt;/strong&gt;: Share your use cases and get help&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;These cookiecutter templates represent hundreds of hours of research, testing, and refinement to create the ideal starting point for Python projects. Whether you're building a simple CLI tool or a complex Django API, you can focus on your core business logic instead of wrestling with configuration.&lt;/p&gt;

&lt;p&gt;The templates embody modern Python best practices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Type safety&lt;/strong&gt; with comprehensive type checking&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code quality&lt;/strong&gt; with automated formatting and linting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing excellence&lt;/strong&gt; with comprehensive test setups&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production readiness&lt;/strong&gt; with Docker and deployment configs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Developer experience&lt;/strong&gt; with helpful scripts and documentation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Give them a try for your next project. I'm confident they'll save you hours of setup time and help you ship better code faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What are you waiting for? Let's build something amazing together!&lt;/strong&gt; 🚀&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have you used these templates? Found them helpful? Have suggestions for improvements? I'd love to hear about your experience! Drop a comment below or open an issue on GitHub.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>cookiecutter</category>
      <category>bestpractices</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Easy Multilingual Page (Scales to Over 50 Languages)</title>
      <dc:creator>Huy Nguyen</dc:creator>
      <pubDate>Fri, 21 Jun 2024 14:17:13 +0000</pubDate>
      <link>https://dev.to/huynguyengl99/easy-multilingual-page-scales-to-over-50-languages-3m4o</link>
      <guid>https://dev.to/huynguyengl99/easy-multilingual-page-scales-to-over-50-languages-3m4o</guid>
      <description>&lt;p&gt;Hi all,&lt;/p&gt;

&lt;p&gt;So, a few months back, I kicked off a project to create a multilingual website. Let me tell you, managing content in multiple languages is a real headache. It’s doable if you’re only dealing with 3 or 4 languages, but once you hit 30+, it’s a whole different ball game trying to keep everything updated and consistent.&lt;/p&gt;

&lt;p&gt;To make life easier, I decided to bring in a CMS for my Astro-based site. I dug into frameworks like Wagtail and Strapi since I’m familiar with Python and Node.js, but man, those were tough nuts to crack. Plus, I wanted a centralized place for all my content instead of having to jump between different pages for each language. This way, I can easily compare content across languages without getting lost.&lt;/p&gt;

&lt;p&gt;And here's the kicker: ChatGPT is shaping up to be a solid translator, but no one’s integrated it into any frameworks yet. So, with my Django and Astrowind know-how (Astrowind is this cool UI built on Astro and Tailwind, and it’s open source), I whipped up a small Django library/framework to act as a headless CMS. I also tweaked the Astrowind source to pull in content from the CMS with multilingual support.&lt;/p&gt;

&lt;p&gt;Check out this demo if you wanna see it in action:&lt;br&gt;
&lt;a href="https://django-astrowind.netlify.app/en"&gt;https://django-astrowind.netlify.app/en&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Not much has changed from the original Astrowind (&lt;a href="https://astrowind.vercel.app/"&gt;https://astrowind.vercel.app/&lt;/a&gt;), except now you can switch languages on the fly, and the content is managed through a CMS with hashed caching.&lt;/p&gt;

&lt;p&gt;If you’re interested in using it, I’ve put together a tutorial to help you set it up on your own machine:&lt;br&gt;
&lt;a href="https://django-headless-cms.readthedocs.io/en/latest/quick-start.html"&gt;https://django-headless-cms.readthedocs.io/en/latest/quick-start.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hope you dig it. And I hope my little project can be of some help to you guys.&lt;/p&gt;

</description>
      <category>django</category>
      <category>astrojs</category>
      <category>multilingual</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
