# 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](app-api.md); this document builds on it.

---

## 1. Architecture

```mermaid
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.<br/>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`

```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:

```bash
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](app-api.md#one-key-per-calling-system).
For details, see [app-api.md — Getting the key](app-api.md#getting-the-key).

### 3.2 Routing each role to a different agent (optional)

```yaml
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

```bash
# 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:

```json
{
  "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

```python
# 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.

```python
# 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

```python
# 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:

* **per IP** and **per user_id**, each at `channels.app_api.rate_limit_per_min` (default **60/min**);
* the **gateway-wide** limit, `gateway.rate_limit.per_minute` (default **60/min**) per connecting address. It counts every request that does not carry the gateway token, app API requests included.

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:

* With the defaults → the whole company shares 60 requests/min, then everyone gets `429`.
* The fix:
  1. Django passes the end user's IP in `X-Forwarded-For`.
  2. When Django connects to GoClaw directly and nothing else can reach GoClaw's port, set
     `gateway.rate_limit.trust_proxy: true`, so the limits are kept per end-user IP. (With a
     proxy in between, have it append Django's header as it is; GoClaw reads the last entry.)
  3. Set `rate_limit_per_min` 3–5 times above the real load (600, for example), and raise
     `gateway.rate_limit.per_minute` the same way: without `trust_proxy` they are the only fix.

```yaml
gateway:
  rate_limit:
    per_minute: 600
```

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

### 4.5 GoClaw client

```python
# 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

```python
# 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
```

```python
# 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.

```dart
// 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

```dart
// 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:**

* When `tool_start` arrives, show a "🔧 Running {tool}…" indicator — the agent may think for 30+ seconds, and the user must not think the app froze.
* A "Stop" button is required (`cancel()`).
* An `error` may arrive without a `done` after it — guard with a timeout.

---

## 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](app-api.md).

### 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

- [ ] `GOCLAW_API_KEY` is **not** in the Flutter binary (check with `strings`)
- [ ] `X-User-ID` is set by Django **on the server side**; it is **never** taken from the request body
- [ ] `X-User-ID` is stable, and a deleted user's ID is never reused
- [ ] The Django → GoClaw connection uses TLS or is closed within the internal network
- [ ] The GoClaw port (8080) is not open to the internet (`gateway.bind: loopback` + reverse proxy)
- [ ] Every `thread_id` is checked against its owner (`user=request.user` filter) — writing to another person's thread is impossible
- [ ] Error messages do not leak GoClaw's internal paths or session_id
- [ ] Django has a rate limit of its own (one user cannot saturate GoClaw)

---

## 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:

* **The event stream (`GET /api/app/events`)** — only if the agent needs to push messages on its own initiative (cron reports, SOP results). SSE replies are enough for plain question and answer. `/ws/app` is retired and answers `410`. Note: a proactive message reaches only an **open** stream; if the app is closed it is lost. Push notifications are a separate matter. See [app-api.md — Event Stream Endpoint](app-api.md#4-event-stream-endpoint-get-apiappevents).
* **The `context_meta` field** — defined in `ChatRequest`, but the server **does not read it** yet. Do not build features on it.
* **File attachments** — `/api/media/upload` is a separate flow, for a later phase.
* **`auth_mode: "jwt"`** — only if Flutter must reach GoClaw **directly**. It is not needed in the Django proxy architecture.

---

## Related documents

- [Enterprise App API Channel](app-api.md) — the full API reference for the GoClaw side
- [Configuration](../configuration.md)
- [Pairing & per-user memory](pairing.md)
- [Ingress / reverse proxy](../ingress.md)
