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

Enterprise App API Channel — Mobile App & Internal Systems Integration

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.


Key Capabilities

  1. No Manual Pairing Challenges: Employees already authenticate against your company's identity provider (SSO / Keycloak / Active Directory / App Auth). GoClaw trusts authenticated headers or signed JWTs from your backend.
  2. Per-User Memory Isolation: Each employee has their own private conversation history and personal memory (records of scope 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.
  3. Multi-Agent Routing by Role/Dept: Route requests automatically (e.g. role: "engineer" → DevOps Agent, role: "accountant" → Financial Analysis Agent).
  4. Real-time Streaming: Native Server-Sent Events (text/event-stream) for responsive typing animations.
  5. Agent-initiated Messages: A long-lived event stream (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.


Security Model — read before enabling

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:

  1. A credential is mandatory. With 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.
  2. 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.
  3. JWT mode is verified, not decoded. Tokens must be 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.


Getting the key

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.

jwt mode has the same shape: jwt_secret is a shared secret — goclaw secret gen APP_JWT_SECRET works for it too.

One key per calling system

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

Configuration (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 Reference

Which URL to call

/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.

1. REST Chat Endpoint (POST /api/app/chat)

Request Headers:

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.

Error Responses:

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.

Request Body:

{
  "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.


2. Streaming Response (SSE — 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"}

3. Synchronous Response (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
}

4. Event Stream Endpoint (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:

Reconnect with the usual EventSource backoff; the stream carries no per-turn state, so reopening it is always safe.


5. WebSocket Endpoint (GET /ws/app — DEPRECATED)

[!WARNING] Deprecated (HTTP 410 Gone): The WebSocket endpoint /ws/app has been deprecated in favor of on-demand Server-Sent Events (SSE) via POST /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 use POST /api/app/chat with 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

Client Integration Examples

cURL Example

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
  }'

TypeScript / Node.js (backend proxy)

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.

Flutter / Dart (SSE Stream)

The snippet below points at your own backend proxy, which adds the Authorization and X-User-* headers server-side. Do not embed APP_GATEWAY_API_KEY in 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'];
        }
      }
    }
  }
}