The App API Channel (app_api) allows enterprise organizations to embed GoClaw AI agents directly into their Mobile Applications (iOS, Android, Flutter, React Native) and Internal Systems (ERP, Intranet, CRM, Helpdesk).
It provides Trusted Identity Resolution (eliminating the need for manual 8-character pairing codes), Per-Employee Memory Isolation (user_<emp_id>.md), SSE streaming of replies plus an SSE event stream for agent-initiated messages, and Role-Based Agent Routing.
personal in the state database, keyed by the employee's user ID and shared by every agent). Company-wide RAG and SOPs are shared, but personal notes/preferences remain completely private.role: "engineer" → DevOps Agent, role: "accountant" → Financial Analysis Agent).text/event-stream) for responsive typing animations.GET /api/app/events) delivers what the agent says outside a reply — cron reports, SOP results, finished delegations.The API carries text only. A file the agent sends to an app user arrives on the event stream as its name ([Attachments: report.xlsx]), and the chat response has no file field.
The app_api routes (/api/app/*, /ws/app) are exempt from the gateway dashboard token (gateway.auth.token), because they authenticate their own callers with the app credential below. Three consequences follow:
auth_mode: "api_key" and no api_key — or auth_mode: "jwt" and no jwt_secret — the channel refuses to start and every request is answered 503. There is no anonymous mode.api_key mode trusts the X-User-* headers. Whoever holds the key may present themselves as any employee, and each employee's identity selects their private memory file and session. Do not ship the key inside a mobile app binary — put it in your own backend and have the app talk to that, or use auth_mode: "jwt" for clients you cannot trust with a shared secret.HS256, signed with jwt_secret, and carry an exp claim; expired tokens, wrong signatures and alg: none are rejected. A 60-second clock skew is tolerated. An optional nbf claim is honoured.Requests are rate limited per client IP and per user ID (rate_limit_per_min, default 60/min each); over-budget callers get 429 with a Retry-After header. The client IP is the connecting address, or the last X-Forwarded-For entry with gateway.rate_limit.trust_proxy: true.
The gateway-wide limit applies on top of that: gateway.rate_limit.per_minute (default 60 a minute) per client address, for every request that does not carry the gateway token, so app API requests count against it. It is keyed by the connecting address, or by the last X-Forwarded-For entry with gateway.rate_limit.trust_proxy: true. Behind a proxy, all callers share the proxy's address, so raise per_minute to cover the proxy's peak load. Over budget, it answers 429 with {"error":"rate limit exceeded","retry_after":60} and Retry-After: 60.
There is nothing to request and nothing to click: api_key is a pre-shared
secret you generate yourself, exactly like a password you choose for two of
your own systems. GoClaw never issues it, and no wizard creates one — the
channel only compares what a caller presents against what your config holds.
GoClaw can generate and store one for you:
goclaw secret gen APP_GATEWAY_API_KEY --provider app_api
It prints the value once — hand that to the caller — and keeps it in the
encrypted vault. Re-read it later with goclaw secret get APP_GATEWAY_API_KEY.
Regenerating over an existing name needs --force, because the callers still
presenting the old value will start failing.
If you would rather produce the value yourself, any CSPRNG will do
(openssl rand -hex 32) and goclaw secret set <name> <value> stores it.
Then give it to GoClaw in one of two ways. Both are referenced from
config.yaml with ${...}, but they are different mechanisms:
In config.yaml |
Where the value lives | When to use it |
|---|---|---|
api_key: "${secret:APP_GATEWAY_API_KEY}" |
GoClaw's encrypted vault (AES-256-GCM, machine-specific), written with goclaw secret set |
Default. The value never appears in the config file or in the process environment. |
api_key: "${APP_GATEWAY_API_KEY}" |
An environment variable on the gateway host | Containers and orchestrators that already inject secrets as env vars (Kubernetes, systemd EnvironmentFile, Docker secrets). |
Into the vault:
goclaw secret set APP_GATEWAY_API_KEY "$(openssl rand -hex 32)"
goclaw secret list # confirm it is there (values masked)
The command is secret, singular, and it takes the key and the value.
On a production host also set GOCLAW_SECRET_KEY before writing anything to the
vault. Without it the vault passphrase is derived from machine and user
identifiers, which the gateway warns about at startup; the encryption then
offers little protection against someone who already has access to the host.
The same string then goes to whatever calls GoClaw — your backend proxy, not a
shipped app. Both forms are resolved when the config is loaded, so a rotated
value takes effect on the next config reload or gateway restart, not the moment
you write it; callers still presenting the old key then start getting 401.
jwtmode has the same shape:jwt_secretis a shared secret —goclaw secret gen APP_JWT_SECRETworks for it too.
A single shared key means a leak forces every integration onto a new secret at
once, and an accepted request cannot be attributed to the system that made it.
api_keys issues one credential each:
channels:
app_api:
enabled: true
auth_mode: "api_key"
api_keys:
- name: "django-proxy"
key: "${secret:APP_KEY_DJANGO}"
- name: "erp"
key: "${secret:APP_KEY_ERP}"
- name: "mobile-bff"
key: "${secret:APP_KEY_MOBILE}"
disabled: true # suspended without losing the entry
goclaw secret gen APP_KEY_DJANGO --provider app_api
goclaw secret gen APP_KEY_ERP --provider app_api
name is required and identifies the caller. It appears in the gateway
log ([app_api] client=erp user=emp_10482 agent=main) and as a client:<name>
tag on the trace, so you can see which system called. It is never returned to
the caller.disabled: true to suspend it
and keep the record. The others keep working.api_key and api_keys can both be set. Every one of them is accepted,
so an existing deployment migrates one caller at a time; requests on the old
single key are attributed to client=api_key.config.yaml)channels:
app_api:
enabled: true
# REQUIRED for auth_mode="api_key": either this single key, or at least one
# enabled entry under api_keys. See "Getting the key" above.
# "${secret:NAME}" reads GoClaw's encrypted vault; "${NAME}" reads an
# environment variable on the gateway host.
api_key: "${secret:APP_GATEWAY_API_KEY}"
# Preferred for more than one caller — one credential per system, revocable
# on its own, and the name is logged with each accepted request.
# api_keys:
# - name: "django-proxy"
# key: "${secret:APP_KEY_DJANGO}"
# auth_mode: "api_key" (default) or "jwt"
auth_mode: "api_key"
# REQUIRED for auth_mode="jwt". HMAC secret used to verify HS256 tokens.
# jwt_secret: "${APP_JWT_SECRET}"
# Register each authenticated employee as a GoClaw user (at the "user" tier, in
# ~/.goclaw/credentials/goclaw-users.json) on first contact. Nothing else is
# created: personal memory is stored as database records when the agent saves
# something about that person. Default: true
auto_provision_memory: true
# Default role if none supplied in headers. Default: "user"
default_role: "user"
# CORS allowlist for browser callers (intranet portals). Requests from a
# browser origin that is not listed are refused with 403. Non-browser
# clients (mobile apps, servers, curl) send no Origin and are unaffected.
# An entry is an exact origin, "*.company.com" or "https://*.company.com"
# (any subdomain and the domain itself; with a scheme, only that scheme),
# or "*" for any origin.
allowed_origins:
- "https://portal.company.internal"
- "http://localhost:3000"
# Enforced per client IP and per user ID (default: 60)
rate_limit_per_min: 60
# Route different employee roles to specialized agents
bindings:
- agent_id: "achilles"
match:
channel: "app_api"
platform_roles: ["engineer", "devops"]
- agent_id: "odysseus"
match:
channel: "app_api"
/api/app/chat and /api/app/events are served on GoClaw's main port — the
same one the Web UI answers on — not on gateway.ingress.port, which carries
only the webhook routes. (/ws/app is retired; see below.)
Two settings decide whether a caller can reach it at all:
| Setting | Default | Effect |
|---|---|---|
server.port |
8080 |
The port. The 8080 in the examples below is this default, not a fixed value — check the config of the deployment you are calling. |
gateway.bind |
loopback |
With the default, GoClaw listens on 127.0.0.1 only and is unreachable from another host. Use lan to listen on all interfaces, or keep loopback and put a reverse proxy in front. |
The gateway prints both at startup: GoClaw gateway starting on 127.0.0.1:8080.
/health needs no credential, so it settles in one command whether you are
talking to GoClaw at all:
curl -s http://<host>:<port>/health
# {"status":"ok","uptime_seconds":...}
If that returns HTML, an nginx page, or nothing, the address is wrong or something else is answering on that port — no API key will fix it.
POST /api/app/chat)| Header | Required | Description |
|---|---|---|
Authorization |
Yes | Bearer <APP_GATEWAY_API_KEY> in api_key mode, Bearer <JWT> in jwt mode. This is the app credential, not gateway.auth.token — these routes do not take the dashboard token. X-API-Key: <key> works instead. |
Content-Type |
Yes | application/json |
X-User-ID |
Yes* | Unique employee / user identifier (e.g. emp_10482, usr_abc) |
X-User-Name |
Optional | Display name of the employee (e.g. Alice) |
X-User-Role |
Optional | Business role, used for agent routing: with X-User-Dept it forms the platform_roles that bindings match (e.g. engineer, manager). It grants no GoClaw privileges: every app API request runs at GoClaw's user tier, whatever this header says. |
X-User-Dept |
Optional | Department (e.g. infrastructure, finance) |
X-Tenant-ID |
Optional | Organization or branch ID (e.g. hq, branch_ub) |
* When using auth_mode: "jwt", identity fields come from the verified JWT claims (sub, name, email, role, dept, tenant) and the X-User-* headers are ignored. A request with no resolvable user ID is rejected with 401 — there is no anonymous or IP-derived identity.
| Status | Meaning |
|---|---|
404 |
The request never reached this endpoint. The route is registered whenever the gateway runs, so a 404 is not about the channel or the key — it is the wrong host, the wrong port, or a reverse proxy that does not forward /api/app/*. GoClaw's own 404 is the plain text 404 page not found; an HTML body means something else answered. Check /health first. |
400 |
The body is not valid JSON (malformed JSON request), or message is empty (message is required) |
401 |
Missing/invalid credential, or no user identity in the request. Note this endpoint does not accept gateway.auth.token — the dashboard token is a different credential; use channels.app_api.api_key or an api_keys entry. |
403 |
Browser Origin is not in allowed_origins. A plain-text Forbidden instead comes from the ingress allowlist (gateway.ingress.allow_preset / allow_ips), which covers the main port when gateway.ingress.port is not set |
405 |
Wrong method: the chat endpoint takes POST, the event stream GET |
408 |
session busy or request cancelled: an earlier request on the same session was still running and the caller gave up waiting. Requests on one session run one at a time |
429 |
Over rate_limit_per_min for this IP or user, or over the gateway-wide limit; see Retry-After |
500 |
Processing failed (usually the agent or its model provider); error says why |
503 |
Channel disabled, or enabled with no credential configured. On /api/app/events also app_api channel is not running (see the event stream notes) |
Errors from these endpoints are JSON: {"error": "..."}. The plain-text 404 and ingress 403 come from in front of them.
{
"message": "Can you check the server metrics for today?",
"stream": true,
"session_id": "optional-custom-session-id"
}
session_id is optional and always scoped to the caller: GoClaw trims it, cuts it to 128 characters and builds the session key app_api:<user ID>/<session_id>, both parts URL-escaped (app_api:emp_10482/optional-custom-session-id here). Without one the key is app_api:<user ID>. One employee can therefore hold several conversations, and no session ID reaches another employee's conversation. Requests on the same session run one after another.
stream: true)Content-Type: text/event-stream
data: {"delta":"Certainly","event":"token","session_id":"app_api:emp_10482"}
data: {"delta":", checking","event":"token","session_id":"app_api:emp_10482"}
data: {"event":"tool_start","tool":"uptrace_query","session_id":"app_api:emp_10482"}
data: {"event":"tool_result","tool":"uptrace_query","session_id":"app_api:emp_10482"}
data: {"delta":" CPU usage is at 24%.","event":"token","session_id":"app_api:emp_10482"}
data: {"done":true,"event":"done","session_id":"app_api:emp_10482"}
If the agent fails, an error frame arrives, followed by done while the client is still connected:
data: {"event":"error","error":"...","session_id":"app_api:emp_10482"}
stream: false)Content-Type: application/json
{
"response": "Certainly, CPU usage is at 24% and all services are healthy.",
"session_id": "app_api:emp_10482",
"user_id": "emp_10482",
"user_name": "Alice",
"role": "engineer",
"prompt_tokens": 182,
"output_tokens": 45,
"elapsed_ms": 780
}
GET /api/app/events)POST /api/app/chat answers one question and closes. Anything the agent says on
its own — a cron report, an SOP result, a subagent finishing a delegated task —
has no reply to ride along with, so it needs somewhere to go. That is this
endpoint: one long-lived SSE stream per client, held open alongside the per-turn
chat calls.
GET /api/app/events
Authorization: Bearer <APP_GATEWAY_API_KEY>
X-User-ID: emp_10482
Browsers cannot set headers on an EventSource, so the credential and identity
may travel as query parameters instead — the same allowance the WebSocket had.
Native clients should prefer headers; query strings end up in proxy and access
logs.
https://goclaw.company.internal/api/app/events?token=<APP_GATEWAY_API_KEY>&user_id=emp_10482
Frames are the same StreamChunk JSON objects as the chat SSE:
data: {"event":"ready","session_id":"app_api:emp_10482"}
: keepalive
data: {"event":"message","delta":"The morning report is ready","session_id":"emp_10482","done":true}
Notes:
ready frame is the handshake: once it arrives, the client is registered
and proactive messages will be delivered.: keepalive) is sent every 30s so a dead proxy surfaces as a
disconnect instead of a silent hang. Every SSE client ignores it.allowed_origins.app_api channel to have started with the gateway. If the
channel was enabled (or its credential fixed) by a config reload, the stream
answers 503 app_api channel is not running until the gateway restarts;
POST /api/app/chat works without the restart.Reconnect with the usual EventSource backoff; the stream carries no per-turn
state, so reopening it is always safe.
GET /ws/app — DEPRECATED)[!WARNING] Deprecated (HTTP 410 Gone): The WebSocket endpoint
/ws/apphas been deprecated in favor of on-demand Server-Sent Events (SSE) viaPOST /api/app/chat(stream: true). WebSocket keepalive timeouts and socket concurrency locks under heavy LLM processing turns (>30s) previously caused request dropping and agent deadlocks. All client integrations (mobile and web) MUST usePOST /api/app/chatwith SSE.
Connecting to /ws/app now returns HTTP 410 Gone with instructions to migrate.
Migration map:
Old (/ws/app) |
New |
|---|---|
Sending {"message": "..."} over the socket |
POST /api/app/chat with stream: true |
| Receiving turn output over the socket | SSE response of that same POST |
| Receiving agent-initiated (proactive) messages | GET /api/app/events |
8080 here is the default server.port — substitute the port the target
deployment actually listens on.
curl -X POST http://localhost:8080/api/app/chat \
-H "Authorization: Bearer $APP_GATEWAY_API_KEY" \
-H "X-User-ID: emp_10482" \
-H "X-User-Name: Alice" \
-H "X-User-Role: engineer" \
-H "Content-Type: application/json" \
-d '{
"message": "Hello Agent!",
"stream": false
}'
Runs on your server, where the shared key belongs. Your mobile app calls this, not GoClaw directly.
async function askGoClaw(employeeId: string, employeeName: string, role: string, message: string) {
const response = await fetch("https://goclaw.company.internal/api/app/chat", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.APP_GATEWAY_API_KEY}`,
"X-User-ID": employeeId,
"X-User-Name": employeeName,
"X-User-Role": role,
"Content-Type": "application/json"
},
body: JSON.stringify({ message, stream: false })
});
return await response.json();
}
Running Flutter + Django? A step-by-step assignment for connecting this channel to a Flutter app through a Django proxy is in flutter-django-integration.md.
The snippet below points at your own backend proxy, which adds the
AuthorizationandX-User-*headers server-side. Do not embedAPP_GATEWAY_API_KEYin a shipped app — see Security Model.
import 'dart:convert';
import 'package:http/http.dart' as http;
Stream<String> streamChat(String empId, String empName, String message, String backendSessionToken) async* {
final request = http.Request('POST', Uri.parse('https://api.company.internal/goclaw/chat'));
request.headers.addAll({
// Session token issued by your backend, which holds the GoClaw key.
'Authorization': 'Bearer $backendSessionToken',
'X-User-ID': empId,
'X-User-Name': empName,
'Content-Type': 'application/json',
});
request.body = jsonEncode({'message': message, 'stream': true});
final response = await request.send();
await for (final line in response.stream.transform(utf8.decoder).transform(const LineSplitter())) {
if (line.startsWith('data: ')) {
final jsonStr = line.substring(6).trim();
if (jsonStr.isNotEmpty) {
final data = jsonDecode(jsonStr);
if (data['delta'] != null) {
yield data['delta'];
}
}
}
}
}