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.
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
This is the key architectural decision; do not skip it:
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.X-User-ID for GoClaw on the server side. The app never chooses this value.| 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) |
config.yamlchannels:
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.
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.
# 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.
# 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.
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.
# 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}
GoClaw counts app API requests in three buckets:
channels.app_api.rate_limit_per_min (default 60/min);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:
429.X-Forwarded-For.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.)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.gateway:
rate_limit:
per_minute: 600
A 429 carries a Retry-After header — Django passes it on to the app.
# 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
# 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"),
]
| 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).
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).',
};
}
}
// 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:
tool_start arrives, show a "🔧 Running {tool}…" indicator — the agent may think for 30+ seconds, and the user must not think the app froze.cancel()).error may arrive without a done after it — guard with a timeout.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.
| 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 |
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 bodyX-User-ID is stable, and a deleted user's ID is never reusedgateway.bind: loopback + reverse proxy)thread_id is checked against its owner (user=request.user filter) — writing to another person's thread is impossible| # | 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 |
| # | 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.
| 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) |
These are not part of v1; they are for a later phase:
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.context_meta field — defined in ChatRequest, but the server does not read it yet. Do not build features on it./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.