🏠 Home ← Docs Index
Open Beta 📖 Interactive Reader Raw .md llms.txt

Work Assignment — Flutter → Django → GoClaw AI Assistant Integration

Version: 1.0 Date: 2026-08-28 Scope: Add an AI assistant to a Flutter mobile app. The Django backend proxies to GoClaw's app_api channel.

This guide adds an AI assistant to a Flutter mobile app: the app talks to its own Django backend, and Django proxies each request to GoClaw's app_api channel, so the gateway key never ships inside the app.

This is an implementation guide. The full reference for the GoClaw side of the API is in app-api.md; this document builds on it.


1. Architecture

flowchart LR
    A["Flutter App<br/>(iOS / Android)"] -->|"HTTPS + the user's<br/>session token"| B["Django Backend"]
    B -->|"HTTPS<br/>Authorization: Bearer APP_KEY<br/>X-User-ID, X-User-Role..."| C["GoClaw Gateway<br/>POST /api/app/chat"]
    C --> D["LLM + Tools + Memory"]
    D --> C
    C -->|"SSE: token / tool_start / done"| B
    B -->|"SSE relay"| A

Why Django has to sit in the middle

This is the key architectural decision; do not skip it:

  1. Never ship APP_GATEWAY_API_KEY inside the app. With auth_mode: "api_key", whoever holds the key can act as any employee through the X-User-ID header. Unpacking the app binary is enough to get the key.
  2. Only Django knows who the user really is. Django authenticates the user with its own session/JWT, then adds X-User-ID for GoClaw on the server side. The app never chooses this value.
  3. Auditing, rate limiting and logging are centralised in Django.

2. Prerequisites

Item Requirement
GoClaw A running gateway (goclaw doctor clean)
Django ≥ 5.0, in ASGI mode (uvicorn / daphne). WSGI does not suit SSE.
5.0 is required for request.auser()
Python httpx (async streaming)
Flutter The http or dio package
Network A direct connection from Django to GoClaw (same VPC / internal DNS)

3. Layer 1 — GoClaw configuration

3.1 config.yaml

channels:
  app_api:
    enabled: true

    # The secret key shared with Django. Required —
    # without it the channel does not start and every request returns 503.
    api_key: "${secret:APP_GATEWAY_API_KEY}"

    auth_mode: "api_key"

    # Registers each employee as a GoClaw user on first contact. Their personal
    # memory is stored as database records when the agent saves something about
    # them.
    auto_provision_memory: true

    default_role: "user"

    # Requests from Django carry no Origin header, so this list has no
    # effect in this architecture. It matters only for direct browser calls.
    allowed_origins: []

    # ⚠ Read section 4.4 — this value matters behind a Django proxy
    rate_limit_per_min: 600

Create the key yourself and put it in the vault. GoClaw does not issue keys — this is a secret of your own choosing, shared between your two systems:

goclaw secret gen APP_GATEWAY_API_KEY --provider app_api
goclaw secret list                      # the value is shown masked

secret gen creates a random value, puts it in the vault and prints it once — set it on the Django side. You can read it again later with goclaw secret get <name>. If you prepared a value yourself, use goclaw secret set <name> <value>.

When several systems connect (Django, ERP, mobile), give each its own key — see the api_keys list in app-api.md — One key per calling system. For details, see app-api.md — Getting the key.

3.2 Routing each role to a different agent (optional)

bindings:
  - agent_id: "devops-agent"
    match:
      channel: "app_api"
      platform_roles: ["engineer", "devops"]

  - agent_id: "main"
    match:
      channel: "app_api"

platform_roles is built automatically from X-User-Role + X-User-Dept.

3.3 Confirming the configuration works

# 8080 is the default `server.port` — check the port of your own
# installation. `gateway.bind` defaults to loopback, so reaching it from
# outside needs `bind: lan` or a reverse proxy in front.
# First of all: curl -s http://GOCLAW_HOST:PORT/health
curl -s -X POST http://GOCLAW_HOST:8080/api/app/chat \
  -H "Authorization: Bearer $APP_GATEWAY_API_KEY" \
  -H "X-User-ID: emp_test_1" \
  -H "X-User-Name: Test" \
  -H "Content-Type: application/json" \
  -d '{"message":"Hello","stream":false}' | jq

Expected response:

{
  "response": "Hello! ...",
  "session_id": "app_api:emp_test_1",
  "user_id": "emp_test_1",
  "user_name": "Test",
  "role": "user",
  "prompt_tokens": 182,
  "output_tokens": 45,
  "elapsed_ms": 780
}

Acceptance criterion A1: the curl above returns 200. The log shows [app_api] Enterprise App & Mobile API channel enabled.


4. Layer 2 — Django

4.1 Settings

# settings.py
import os

GOCLAW_BASE_URL = os.environ["GOCLAW_BASE_URL"]        # http://goclaw.internal:8080
# GoClaw itself serves plain HTTP; use an https:// URL only for a TLS proxy in front of it.
GOCLAW_API_KEY  = os.environ["GOCLAW_API_KEY"]         # the same as APP_GATEWAY_API_KEY
GOCLAW_TIMEOUT_READ = float(os.environ.get("GOCLAW_TIMEOUT_READ", "180"))

Do not write GOCLAW_API_KEY into the code; read it from .env or a secret manager.

4.2 Thread model

Each user must be able to hold several conversations (threads). The GoClaw session key is:

app_api:<X-User-ID>                    ← when no session_id is sent
app_api:<X-User-ID>/<session_id>       ← when one is sent

Both parts are URL-escaped, and the session_id is trimmed and cut to 128 characters. Conversations stored by earlier releases under app_api:<X-User-ID>:<session_id> are carried over the first time they are used.

# assistant/models.py
import uuid
from django.conf import settings
from django.db import models


class AssistantThread(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
                             related_name="assistant_threads")
    title = models.CharField(max_length=200, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    @property
    def goclaw_session_id(self) -> str:
        # The session_id passed to GoClaw. GoClaw keeps the first 128
        # characters (a UUID has 36), so it must be stable and unique within them.
        return str(self.id)


class AssistantMessage(models.Model):
    ROLE_CHOICES = [("user", "user"), ("assistant", "assistant")]

    thread = models.ForeignKey(AssistantThread, on_delete=models.CASCADE,
                              related_name="messages")
    role = models.CharField(max_length=16, choices=ROLE_CHOICES)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["created_at"]

Note: GoClaw keeps its own conversation history. Storing it again in Django is only for showing and searching history in the app's UI. The two histories may drift apart — treat GoClaw as the source of truth.

4.3 Identity headers

# assistant/goclaw.py
from django.conf import settings


def user_identity_headers(user, *, client_ip: str | None = None) -> dict[str, str]:
    headers = {
        "Authorization": f"Bearer {settings.GOCLAW_API_KEY}",
        "Content-Type": "application/json",

        # A stable ID that is never reused. This value selects the employee's
        # PERSONAL memory file, so it must never change or be reused.
        # Use a UUID field rather than the AutoField pk.
        "X-User-ID": str(user.profile.public_uid),
        "X-User-Name": user.get_full_name() or user.get_username(),
        "X-User-Email": user.email or "",
        "X-User-Role": resolve_role(user),          # "engineer" | "manager" | ...
        "X-User-Dept": resolve_department(user),    # "infrastructure" | ...
        "X-Tenant-ID": resolve_tenant(user),        # branch / organisation
    }
    if client_ip:
        # See 4.4 — this value decides the rate-limit IP bucket
        headers["X-Forwarded-For"] = client_ip
    return {k: v for k, v in headers.items() if v}

4.4 ⚠ Rate limits — the most common mistake

GoClaw counts app API requests in three buckets:

Behind a Django proxy every employee arrives from one IP: both the per-IP bucket and the gateway-wide limit are keyed by the connecting address, unless gateway.rate_limit.trust_proxy is true. Then both take the last X-Forwarded-For entry. So:

gateway:
  rate_limit:
    per_minute: 600

A 429 carries a Retry-After header — Django passes it on to the app.

4.5 GoClaw client

# assistant/goclaw.py (continued)
import json
from typing import AsyncIterator

import httpx
from django.conf import settings


class GoClawError(Exception):
    def __init__(self, status: int, message: str, retry_after: int | None = None):
        super().__init__(message)
        self.status = status
        self.message = message
        self.retry_after = retry_after


def _timeout() -> httpx.Timeout:
    # The read timeout must be long: the agent may spend 30–120 s running tools
    return httpx.Timeout(connect=5.0, read=settings.GOCLAW_TIMEOUT_READ,
                         write=10.0, pool=5.0)


def _raise_for_status(status: int, body: bytes, retry_after: str | None) -> None:
    try:
        message = json.loads(body).get("error", "unknown error")
    except (ValueError, AttributeError):
        message = body[:200].decode("utf-8", "replace")
    raise GoClawError(status, message,
                      int(retry_after) if retry_after and retry_after.isdigit() else None)


async def chat(user, message: str, *, session_id: str | None = None,
               client_ip: str | None = None) -> dict:
    """A synchronous (stream=False) reply. Suits short questions."""
    payload: dict = {"message": message, "stream": False}
    if session_id:
        payload["session_id"] = session_id

    async with httpx.AsyncClient(timeout=_timeout()) as client:
        resp = await client.post(
            f"{settings.GOCLAW_BASE_URL}/api/app/chat",
            headers=user_identity_headers(user, client_ip=client_ip),
            json=payload,
        )
        if resp.status_code != 200:
            _raise_for_status(resp.status_code, resp.content,
                              resp.headers.get("Retry-After"))
        return resp.json()


async def stream_chat(user, message: str, *, session_id: str | None = None,
                      client_ip: str | None = None) -> AsyncIterator[dict]:
    """Yields each SSE frame as a dict."""
    payload: dict = {"message": message, "stream": True}
    if session_id:
        payload["session_id"] = session_id

    async with httpx.AsyncClient(timeout=_timeout()) as client:
        async with client.stream(
            "POST",
            f"{settings.GOCLAW_BASE_URL}/api/app/chat",
            headers=user_identity_headers(user, client_ip=client_ip),
            json=payload,
        ) as resp:
            if resp.status_code != 200:
                body = await resp.aread()
                _raise_for_status(resp.status_code, body,
                                  resp.headers.get("Retry-After"))

            async for line in resp.aiter_lines():
                if not line.startswith("data: "):
                    continue
                raw = line[6:].strip()
                if not raw:
                    continue
                try:
                    yield json.loads(raw)
                except ValueError:
                    continue

4.6 Views

# assistant/views.py
import json

from django.http import JsonResponse, StreamingHttpResponse
from django.shortcuts import aget_object_or_404
from django.views.decorators.csrf import csrf_exempt

from .goclaw import GoClawError, chat, stream_chat
from .models import AssistantMessage, AssistantThread


def client_ip(request) -> str:
    """The end user's IP. §4.4 — GoClaw's rate-limit buckets are split by it."""
    xff = request.META.get("HTTP_X_FORWARDED_FOR", "")
    if xff:
        return xff.split(",")[0].strip()
    return request.META.get("REMOTE_ADDR", "")


async def _load(request):
    """Shared checks: method, authentication, thread ownership, message."""
    if request.method != "POST":
        return None, JsonResponse({"error": "method not allowed"}, status=405)

    user = await request.auser()
    if not user.is_authenticated:
        return None, JsonResponse({"error": "unauthenticated"}, status=401)

    try:
        body = json.loads(request.body or b"{}")
    except ValueError:
        return None, JsonResponse({"error": "malformed JSON"}, status=400)

    message = (body.get("message") or "").strip()
    if not message:
        return None, JsonResponse({"error": "message is required"}, status=400)

    # The user=user filter stops anyone writing to another person's thread
    thread = await aget_object_or_404(AssistantThread,
                                      id=body.get("thread_id"), user=user)
    return (user, thread, message), None


# The app signs in with a token, not a session cookie, so CSRF does not apply.
# If you use cookie-based auth, REMOVE csrf_exempt and have the app send
# a CSRF token.
@csrf_exempt
async def chat_sync(request):
    loaded, err = await _load(request)
    if err:
        return err
    user, thread, message = loaded

    await AssistantMessage.objects.acreate(thread=thread, role="user", content=message)

    try:
        result = await chat(user, message,
                            session_id=thread.goclaw_session_id,
                            client_ip=client_ip(request))
    except GoClawError as exc:
        resp = JsonResponse({"error": exc.message}, status=_client_status(exc.status))
        if exc.retry_after:
            resp["Retry-After"] = str(exc.retry_after)
        return resp

    await AssistantMessage.objects.acreate(thread=thread, role="assistant",
                                           content=result["response"])
    return JsonResponse({
        "response": result["response"],
        "thread_id": str(thread.id),
        "elapsed_ms": result.get("elapsed_ms"),
    })


@csrf_exempt
async def chat_stream(request):
    loaded, err = await _load(request)
    if err:
        return err
    user, thread, message = loaded

    await AssistantMessage.objects.acreate(thread=thread, role="user", content=message)

    async def frames():
        parts: list[str] = []
        try:
            async for chunk in stream_chat(user, message,
                                           session_id=thread.goclaw_session_id,
                                           client_ip=client_ip(request)):
                if chunk.get("delta"):
                    parts.append(chunk["delta"])
                # session_id is internal — do not pass it to the app
                chunk.pop("session_id", None)
                yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n".encode()
        except GoClawError as exc:
            payload = {"event": "error", "error": exc.message}
            yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n".encode()
        finally:
            # Keep what was written even if the client disconnected
            if parts:
                await AssistantMessage.objects.acreate(thread=thread, role="assistant",
                                                       content="".join(parts))

    resp = StreamingHttpResponse(frames(), content_type="text/event-stream")
    resp["Cache-Control"] = "no-cache"
    # Turn off nginx buffering — otherwise nothing arrives until the stream ends
    resp["X-Accel-Buffering"] = "no"
    return resp


def _client_status(goclaw_status: int) -> int:
    """Maps GoClaw's status to the status the app receives (see §6)."""
    # A 401 is our configuration error — return 500 so the user is not
    # misled into thinking they are signed out.
    return 500 if goclaw_status == 401 else goclaw_status
# assistant/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path("api/assistant/chat", views.chat_sync, name="assistant-chat"),
    path("api/assistant/stream", views.chat_stream, name="assistant-stream"),
]

4.7 Deployment requirements

Item Value Why
Server uvicorn myproj.asgi:application Async views + SSE
nginx proxy_buffering off; proxy_read_timeout 300s; The stream is not buffered and long replies are not cut off
Django ALLOWED_HOSTS, CSRF CSRF exemption or token auth for POSTs from the app
Healthcheck GET {GOCLAW_BASE_URL}/health Needs no token, returns {"status":"ok"}

Acceptance criterion B1: with curl -N -X POST .../api/assistant/stream the first data: line arrives within 3 seconds (proving buffering is off).


5. Layer 3 — Flutter

5.1 Service

The user-facing strings below are English examples; localize them for your app.

// lib/services/assistant_api.dart
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;

class AssistantEvent {
  final String? delta;      // the text fragment of a "token" event
  final String event;       // token | tool_start | tool_result | done | error
  final String? tool;
  final String? error;
  final bool done;

  AssistantEvent({this.delta, required this.event, this.tool, this.error,
                  this.done = false});

  factory AssistantEvent.fromJson(Map<String, dynamic> j) => AssistantEvent(
        delta: j['delta'] as String?,
        event: (j['event'] as String?) ?? 'token',
        tool: j['tool'] as String?,
        error: j['error'] as String?,
        done: (j['done'] as bool?) ?? false,
      );
}

class AssistantApi {
  AssistantApi({required this.baseUrl, required this.sessionToken});

  final String baseUrl;         // https://api.example.mn
  final String sessionToken;    // the token Django issued

  http.Client? _client;

  /// Streaming mode. Shows the reply in the UI as it arrives.
  Stream<AssistantEvent> stream(String threadId, String message) async* {
    final client = http.Client();
    _client = client;

    try {
      final req = http.Request('POST', Uri.parse('$baseUrl/api/assistant/stream'))
        ..headers.addAll({
          'Authorization': 'Bearer $sessionToken',
          'Content-Type': 'application/json',
          'Accept': 'text/event-stream',
        })
        ..body = jsonEncode({'thread_id': threadId, 'message': message});

      final resp = await client.send(req);

      if (resp.statusCode != 200) {
        final body = await resp.stream.bytesToString();
        yield AssistantEvent(
          event: 'error',
          error: _errorMessage(resp.statusCode, body),
        );
        return;
      }

      // In streaming mode Utf8Decoder joins multi-byte characters correctly
      // across chunks — Cyrillic text does not break.
      final lines = resp.stream
          .transform(utf8.decoder)
          .transform(const LineSplitter());

      await for (final line in lines) {
        if (!line.startsWith('data: ')) continue;
        final raw = line.substring(6).trim();
        if (raw.isEmpty) continue;
        yield AssistantEvent.fromJson(
            jsonDecode(raw) as Map<String, dynamic>);
      }
    } finally {
      client.close();
      _client = null;
    }
  }

  /// When the user taps "Stop".
  void cancel() {
    _client?.close();
    _client = null;
  }

  String _errorMessage(int status, String body) {
    try {
      final decoded = jsonDecode(body) as Map<String, dynamic>;
      final msg = decoded['error'] as String?;
      if (msg != null && msg.isNotEmpty) return msg;
    } catch (_) {}
    return switch (status) {
      401 => 'Your session has expired. Please sign in again.',
      429 => 'Too many requests. Please wait a moment and try again.',
      503 => 'The AI assistant is temporarily unavailable.',
      _ => 'Something went wrong ($status).',
    };
  }
}

5.2 Wiring it into the UI

// lib/controllers/chat_controller.dart
class ChatController extends ChangeNotifier {
  ChatController(this._api);

  final AssistantApi _api;
  final List<ChatMessage> messages = [];

  bool isStreaming = false;
  String? activeTool;          // shown as "Searching..." and the like

  StreamSubscription<AssistantEvent>? _sub;

  Future<void> send(String threadId, String text) async {
    messages.add(ChatMessage.user(text));
    final reply = ChatMessage.assistant('');
    messages.add(reply);
    isStreaming = true;
    notifyListeners();

    _sub = _api.stream(threadId, text).listen(
      (ev) {
        if (ev.event == 'token') {
          reply.text += ev.delta ?? '';
        } else if (ev.event == 'tool_start') {
          activeTool = ev.tool;
        } else if (ev.event == 'tool_result') {
          activeTool = null;
        } else if (ev.event == 'error') {
          reply.error = ev.error;
        } else if (ev.event == 'done') {
          isStreaming = false;
        }
        notifyListeners();
      },
      onError: (e) {
        reply.error = 'Network error';
        isStreaming = false;
        notifyListeners();
      },
      onDone: () {
        isStreaming = false;
        activeTool = null;
        notifyListeners();
      },
    );
  }

  void stop() {
    _api.cancel();
    _sub?.cancel();
    isStreaming = false;
    notifyListeners();
  }

  @override
  void dispose() {
    _sub?.cancel();
    super.dispose();
  }
}

UI requirements:


6. SSE frame reference

Django passes GoClaw's frames through unchanged (it only removes session_id).

event Fields Meaning
token delta The next part of the reply. Append it
tool_start tool The agent started calling a tool
tool_result tool The tool finished
error error An error. A done may not follow
done done: true The end

Details: app-api.md → Streaming Response.

HTTP status codes (GoClaw → Django)

Code Meaning What Django does
400 message empty / malformed JSON Pass on 400
401 Wrong key, or X-User-ID missing Pass on as 500 — this is a server configuration error, not the user's fault. Raise an alert
403 Origin not allowed Should not happen for requests from Django
408 session busy or request cancelled: the request was cancelled while an earlier one on the same thread was still running (requests on one thread run one at a time) Pass on 408; the app retries once the earlier reply has arrived
429 Rate limit (rate_limit_per_min or the gateway-wide limit) Pass on 429 + Retry-After
500 The agent or its model provider failed; error says why Pass on 500 with a generic message; log GoClaw's error
503 Channel off / key not configured 503 + "The AI assistant is temporarily unavailable". Raise an alert

7. Security checklist


8. Tests and acceptance criteria

# Criterion How to check
A1 GoClaw app_api is on §3.3 curl → 200
A2 A request without the key is refused curl without Authorization → 401
B1 SSE is not buffered curl -N → first frame < 3 s
B2 Users are isolated Two different users each say "remember my name" → nothing mixes between them
B3 Threads are isolated Two threads of one user → contexts do not mix
B4 429 is passed on correctly Set rate_limit_per_min: 1 and send two requests → the app shows "please wait"
B5 GoClaw is down Stop GoClaw → the app does not crash and shows a clear message
C1 Stream interrupted Tap "Stop" midway → the partial reply is kept
C2 Cyrillic text A long Mongolian reply → no broken characters (UTF-8 chunk boundaries)
C3 Tool indicator A question that needs a search → tool_start is visible in the UI
D1 Long replies A question that runs 60+ s → no timeout

9. Work breakdown

# Task Owner Estimate Depends on
1 Configure GoClaw app_api, put the key in the vault DevOps 0.5 day —
2 AssistantThread / AssistantMessage models + migration Backend 0.5 day —
3 goclaw.py client (sync + stream) Backend 1 day 1
4 Views + URLs + error mapping Backend 1 day 2, 3
5 ASGI deployment, nginx buffering settings DevOps 0.5 day 4
6 AssistantApi Dart service Mobile 1 day 4
7 Chat UI + streaming + tool indicator + stop Mobile 2 days 6
8 Thread list / history screen Mobile 1 day 7
9 The tests in §8 QA 1 day 7
10 Security review (§7) Backend + DevOps 0.5 day 9

Total: ~9 person-days; ~5 working days when done in parallel.


10. Common problems

Symptom Cause Fix
Every request returns 503 api_key not set / channel off Look for [app_api] channel NOT started in the GoClaw log
Every request returns 401 Django sends gateway.auth.token /api/app/* does not accept the dashboard token. Use channels.app_api.api_key
The whole reply arrives at once nginx is buffering proxy_buffering off; + X-Accel-Buffering: no
Everyone gets 429 under load The bucket for Django's single IP is full §4.4 — pass X-Forwarded-For, raise rate_limit_per_min and gateway.rate_limit.per_minute
Users see each other's information X-User-ID is unstable / duplicated Move to a stable UUID
Long replies are cut off midway httpx read timeout / nginx proxy_read_timeout Set both to 300 s
Mongolian text is garbled UTF-8 was decoded across byte-chunk boundaries Use utf8.decoder as a stream transformer (do not decode each line separately)

11. Out of scope

These are not part of v1; they are for a later phase:


Related documents