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

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

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

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

```bash
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.
- **Revoke one by removing its entry**, or set `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`.
- Keys are compared in constant time and every candidate is checked even after a
  match, so response timing does not reveal which credential a probe came close
  to — or which callers exist.

---

## Configuration (`config.yaml`)

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

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

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

- The `ready` frame is the handshake: once it arrives, the client is registered
  and proactive messages will be delivered.
- A comment line (`: keepalive`) is sent every 30s so a dead proxy surfaces as a
  disconnect instead of a silent hang. Every SSE client ignores it.
- One employee may hold several streams open (phone, tablet, desktop); each
  receives a copy.
- Cross-origin browser callers must have their origin listed in
  `allowed_origins`.
- If no stream is open for a user, a proactive send reports non-delivery to the
  agent — the message is not queued for later.
- A client too slow to drain its stream loses messages rather than blocking the
  cron run or SOP that produced them.
- The stream needs the `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.

---

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

```bash
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.

```typescript
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-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](#security-model--read-before-enabling).

```dart
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'];
        }
      }
    }
  }
}
```
