# Grok Build Gateway (Entra JWT → xAI)

Dedicated AgentGateway for **[Grok Build](https://github.com/xai-org/grok-build)**
(xAI CLI / BYOK custom models): Microsoft Entra ID JWT on the data plane, xAI
upstream with the Vault-synced API key. Same pattern as the
[Codex gateway](codex-gateway.md) — dedicated Gateway, Strict JWT, reused JWKS
+ provider secret — Grok Build talks OpenAI-compatible APIs (`/v1/responses` or
`/v1/chat/completions`) and **allows plain HTTP on non-loopback**.

**Related:** [Grok Build lab page](https://goose.maniak.ai/grok-build.html)
([`#config`](https://goose.maniak.ai/grok-build.html#config) ·
[`#agw-config`](https://goose.maniak.ai/grok-build.html#agw-config) ·
[`#links`](https://goose.maniak.ai/grok-build.html#links)) · sibling open xAI path
`:31397/grok` (no Entra, grok-4.5 pinned, used by kagent) on the
[LLM gateway](https://goose.maniak.ai/llm-gateway.html#endpoints).

Official docs:
[Grok Build](https://github.com/xai-org/grok-build) ·
[Grok Build overview](https://docs.x.ai/build/overview) ·
[settings reference](https://docs.x.ai/build/settings/reference) ·
[LLM clients](https://agentgateway.dev/docs/kubernetes/latest/integrations/llm-clients/) ·
[xAI provider](https://agentgateway.dev/docs/standalone/latest/llm/providers/xai/) ·
[Solo openai-compatible (xAI)](https://docs.solo.io/agentgateway/latest/llm/providers/openai-compatible/) ·
[Solo OpenAI provider](https://docs.solo.io/agentgateway/latest/llm/providers/openai/)
(xAI speaks the OpenAI dialect). Solo’s LLM-clients index has Claude Desktop /
Codex / Cursor but **no first-party Grok Build page**.

## Architecture

```
Grok Build (CLI)
  │  Authorization: Bearer <Entra ID token>   (GROK_AGW_TOKEN)
  │  POST {base}/v1/responses   (base URL MUST end in /v1)
  ▼
optional scripts/grok-build-lab-proxy.py   (TCP loopback :18791 → lab HTTP NodePort)
  ▼
grok-build-gateway  (HTTP :31918 / optional HTTPS :31987 on 172.16.10.155)
  │  AgentgatewayPolicy grok-build-jwt-auth
  │    jwtAuthentication Strict → entra-jwks (login.microsoftonline.com)
  │  HTTPRoute grok-build  PathPrefix /
  ▼
AgentgatewayBackend xai-grok-build
  │  provider.openai: {}   (no pinned model — Grok Build sends it)
  │  host api.x.ai:443 pathPrefix /v1 · TLS SNI api.x.ai
  │  injects xai-secret (Vault → ESO)
  ▼
api.x.ai  (/v1/responses, /v1/chat/completions, /v1/models)
```

Grok Build posts to `{base_url}/responses` (when `api_backend = "responses"`)
where `base_url` **must end in `/v1`**, so the live path is
`{NodePort}/v1/responses`. This gateway matches `/` (not `/grok`) so that path
works at the NodePort root. The kagent xAI path stays on `xai-grok-gateway` at
`/grok` (pinned grok-4.5, NodePort 31397).

Grok Build **allows HTTP off loopback** (like Codex), so the LAN NodePort is a
valid base URL. The loopback proxy is optional — useful for a stable
`127.0.0.1:18791` URL that does not change if the NodePort moves. Do not collide
with Claude Desktop (`:18789`) or Codex (`:18790`).

## Recommended lab paths

**LAN (simplest — Grok Build allows non-loopback HTTP):**

```text
http://172.16.10.155:31918/v1
```

**Loopback (optional, same pattern as Codex):**

1. Be on the lab LAN or VPN so `172.16.10.155` is reachable.
2. Start the proxy (defaults to HTTP NodePort **31918**):

   ```bash
   python3 scripts/grok-build-lab-proxy.py
   # or: GROK_BUILD_GW_NODEPORT=31918 python3 scripts/grok-build-lab-proxy.py
   ```

   Listens on `127.0.0.1:18791`, forwards to `172.16.10.155:31918`.

3. Set Grok Build `base_url` to:

   ```text
   http://127.0.0.1:18791/v1
   ```

Script: [`scripts/grok-build-lab-proxy.py`](https://github.com/sebbycorp/k8s-goose/blob/main/scripts/grok-build-lab-proxy.py).

### HTTPS self-signed (optional)

Optional advanced base URL: `https://172.16.10.155:31987/v1` (TLS
terminate with `solo-ui-tls`). Trust the lab cert, or use `curl -k` for smoke
tests. Prefer HTTP unless you have a reason to fight OS trust stores.

If the NodePort ever changes, re-confirm with
`kubectl --context maniak-goose -n agentgateway-system get svc grok-build-gateway`.

## Entra app (already created — do not recreate)

| Field | Value |
|-------|--------|
| App name | `agw-grok-build` |
| Tenant ID | `8635e970-2205-4189-bc77-77519ff5064f` |
| Client ID (Application ID) | `36fb017c-5b4b-4c26-9bd7-7af25793178b` |
| Issuer | `https://login.microsoftonline.com/8635e970-2205-4189-bc77-77519ff5064f/v2.0` |
| Platform | **Mobile and desktop applications** |
| Redirect URI | `http://127.0.0.1/callback` |
| Allow public client flows | **Yes** (`isFallbackPublicClient: true`) |
| Client secret | **none** (public client) |

Gateway JWT `audiences` is this client ID only — that matches **ID-token**
`aud`. Access tokens will fail Strict JWT.

Do not register the redirect under Web. The path must include `/callback`
(`http://127.0.0.1` alone fails with `AADSTS50011`); the port is wildcarded.

## Cluster resources

| Kind | Name | File |
|------|------|------|
| Gateway | `grok-build-gateway` | [`config/gateway/grok-build-gateway.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/gateway/grok-build-gateway.yaml) |
| HTTPRoute | `grok-build` | [`config/routes/grok-build-route.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/routes/grok-build-route.yaml) |
| AgentgatewayBackend | `xai-grok-build` | [`config/backends/xai-grok-build.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/backends/xai-grok-build.yaml) |
| AgentgatewayPolicy | `grok-build-jwt-auth` | [`config/policies/grok-build-jwt-auth.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/policies/grok-build-jwt-auth.yaml) |
| tracing targetRef | Gateway `grok-build-gateway` | [`config/policies/tracing.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/policies/tracing.yaml) |
| Lab proxy script | — | [`scripts/grok-build-lab-proxy.py`](https://github.com/sebbycorp/k8s-goose/blob/main/scripts/grok-build-lab-proxy.py) |

**Reused (do not recreate):**

- `AgentgatewayBackend/entra-jwks` — Entra JWKS host
- `Secret/xai-secret` + ExternalSecret → Vault (same key as `/grok`)
- `Secret/solo-ui-tls` — lab self-signed TLS (same Secret as Solo UI `:30443`;
  must live in `agentgateway-system`, same ns as the Gateway)
- `EnterpriseAgentgatewayParameters/agentgateway-proxy-params` — cost catalog

Argo app `agentgateway-config` syncs `config/` recursively — push + sync is enough.
Do **not** attach this route to `xai-grok-gateway` or steal `/grok`.

## AgentGateway config

Copyable YAML for the four GitOps resources lives on the lab page:
[grok-build.html#agw-config](https://goose.maniak.ai/grok-build.html#agw-config)
(chips + GitHub blobs also at
[#links](https://goose.maniak.ai/grok-build.html#links)).

| Kind | Name | What it does | File |
|------|------|----------------|------|
| Gateway | `grok-build-gateway` | Dedicated HTTP:80 / HTTPS:443 listeners + cost-catalog `parametersRef`. Not `xai-grok-gateway`. | [`config/gateway/grok-build-gateway.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/gateway/grok-build-gateway.yaml) |
| HTTPRoute | `grok-build` | `PathPrefix /` on this Gateway → backend `xai-grok-build`. Does not steal `/grok`. | [`config/routes/grok-build-route.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/routes/grok-build-route.yaml) |
| AgentgatewayBackend | `xai-grok-build` | OpenAI dialect, host `api.x.ai`, no pinned model, reuses `xai-secret`. | [`config/backends/xai-grok-build.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/backends/xai-grok-build.yaml) |
| AgentgatewayPolicy | `grok-build-jwt-auth` | Strict JWT vs Entra JWKS (`entra-jwks`); `aud` = `agw-grok-build` client ID. | [`config/policies/grok-build-jwt-auth.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/policies/grok-build-jwt-auth.yaml) |

Official AgentGateway docs for this path: [LLM clients](https://agentgateway.dev/docs/kubernetes/latest/integrations/llm-clients/) (index — no first-party Grok Build page), [xAI provider](https://agentgateway.dev/docs/standalone/latest/llm/providers/xai/), [Solo openai-compatible (xAI)](https://docs.solo.io/agentgateway/latest/llm/providers/openai-compatible/) (host/path table), [OpenAI provider](https://docs.solo.io/agentgateway/latest/llm/providers/openai/) (dialect).

## Find the NodePort / base URL

Live lab values (Service `grok-build-gateway`):

| Port | Use |
|------|-----|
| **Lab LAN** | `http://172.16.10.155:31918/v1` |
| **Loopback (optional)** | `http://127.0.0.1:18791/v1` via `scripts/grok-build-lab-proxy.py` |
| HTTP **`80:31918/TCP`** | Primary lab path + proxy upstream + debug/curl |
| HTTPS **`443:31987/TCP`** | Optional/advanced direct TLS (`solo-ui-tls`) |

Responses path: `{base}/responses` → `…/v1/responses`.
Models path: `{base}/models` → `…/v1/models` (set `GROK_MODELS_BASE_URL` to the
same `base_url` if Grok Build lists models from `{base}/models`).

If the NodePort ever changes, re-confirm with:

```bash
kubectl --context maniak-goose -n agentgateway-system get svc grok-build-gateway
```

Optional discovery one-liner (examples below use **31918** / **31987**):

```bash
HTTP_NP=$(kubectl --context maniak-goose -n agentgateway-system get svc grok-build-gateway \
  -o jsonpath='{range .spec.ports[*]}{.port}:{.nodePort}{"\n"}{end}' | awk -F: '$1==80{print $2}')
HTTPS_NP=$(kubectl --context maniak-goose -n agentgateway-system get svc grok-build-gateway \
  -o jsonpath='{range .spec.ports[*]}{.port}:{.nodePort}{"\n"}{end}' | awk -F: '$1==443{print $2}')
echo "LAN:      http://172.16.10.155:${HTTP_NP}/v1"
echo "Loopback: http://127.0.0.1:18791/v1   # GROK_BUILD_GW_NODEPORT=${HTTP_NP} python3 scripts/grok-build-lab-proxy.py"
echo "HTTPS:    https://172.16.10.155:${HTTPS_NP}/v1"
```

## Grok Build client config

`~/.grok/config.toml` (user config; project `.grok/config.toml` only contributes
MCP/plugins/permission). **`base_url` must end in `/v1`.**
`GROK_AGW_TOKEN` must be an Entra **ID token** for `agw-grok-build`.

```toml
[model.agw]
model = "grok-4.5"
base_url = "http://172.16.10.155:31918/v1"
name = "Grok via agentgateway"
env_key = "GROK_AGW_TOKEN"
api_backend = "responses"
supports_backend_search = false

[models]
default = "agw"
```

Loopback variant: `base_url = "http://127.0.0.1:18791/v1"`.

`supports_backend_search` **must be false** on this BYOK gateway — xAI
server-side search tools will not exist through AgentGateway.

Grok Build may also set `GROK_MODELS_BASE_URL` for `{base}/models`. This
gateway maps `/v1/models` → `Models`, so the same `base_url` works:

```bash
export GROK_MODELS_BASE_URL="http://172.16.10.155:31918/v1"
```

`api_backend` may be `responses` (recommended), `chat_completions` (maps to
`/v1/chat/completions` Completions), or `messages` (Passthrough). Prefer
`responses` — same wire as Codex.

### Mint an ID token (`GROK_AGW_TOKEN`)

Access tokens (`az account get-access-token`) will fail — gateway `aud` is the
app client ID, which matches an **ID token**. Device-code against the public
client (no secret):

```bash
TENANT=8635e970-2205-4189-bc77-77519ff5064f
CLIENT=36fb017c-5b4b-4c26-9bd7-7af25793178b

# 1. Start device code
curl -sS -X POST "https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/devicecode" \
  -d "client_id=${CLIENT}" \
  -d "scope=openid profile email offline_access"

# 2. Complete the user_code in a browser, then poll:
# DEVICE_CODE=<device_code from step 1>
curl -sS -X POST "https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
  -d "client_id=${CLIENT}" \
  -d "device_code=${DEVICE_CODE}"
# → copy id_token (not access_token)

export GROK_AGW_TOKEN='<id_token>'
```

ID tokens expire in ~1h; `offline_access` yields a refresh_token you can
exchange later.

## Smoke test (curl + Entra ID token)

Anonymous must fail; a real Entra **ID token** for this app must reach xAI.

```bash
BASE="http://172.16.10.155:31918"
# or: BASE="http://127.0.0.1:18791"   # with the lab proxy running

# Expect 401/403
curl -sS -o /tmp/grok-build-anon -w '%{http_code}\n' -m 30 \
  -H 'Content-Type: application/json' \
  -d '{"model":"grok-4.5","input":"ping"}' \
  "${BASE}/v1/responses"

# Then:
# curl -sS ... -H "Authorization: Bearer ${GROK_AGW_TOKEN}" ... "${BASE}/v1/responses"
```

For optional HTTPS smoke without trusting the cert:
`BASE="https://172.16.10.155:31987"` and `curl -skS …`.

## Lessons learned

1. **`base_url` must end in `/v1`** — Grok Build concatenates `/responses` onto
   that base (`POST …/v1/responses`). A URL without `/v1` 404s.
2. **Dedicated Gateway + PathPrefix `/`** — keep `/grok` on
   `xai-grok-gateway` for kagent; Grok Build expects `/v1/…` at the gateway root.
3. **Do not pin a model** on `xai-grok-build` — Grok Build sends the model per
   request (`provider.openai: {}` plus host `api.x.ai`).
4. **Bearer = ID token** — gateway `aud` = `agw-grok-build` client ID. Access
   tokens fail Strict JWT. Grok Build `env_key` = `GROK_AGW_TOKEN`.
5. **Use `jwtAuthentication` (AgentgatewayPolicy), not `entExtAuth`** — same
   pattern as Codex / Claude Desktop / `/secure-openai` / `/mcp-secure`.
6. **Reuse `entra-jwks` + `xai-secret`** — one JWKS backend and one Vault
   key; do not recreate the Entra app.
7. **HTTP is OK off loopback** — like Codex, unlike Claude Desktop. LAN
   NodePort is the primary path; loopback proxy is optional (`:18791`).
8. **`supports_backend_search` = false** — xAI server-side search tools will
   not exist through this BYOK AgentGateway path.
9. **`/v1/models` is mapped** — set `GROK_MODELS_BASE_URL` to the same
   `base_url` if the client lists models from `{base}/models`.
10. **Add the Gateway to `tracing.yaml`** — attachment is explicit; a new
    Gateway emits no spans until listed.

## Teardown

Removes only the Grok Build resources (keeps `entra-jwks`, `xai-secret`,
`solo-ui-tls`, and the kagent `/grok` route):

```bash
kubectl --context maniak-goose -n agentgateway-system delete \
  agentgatewaypolicy grok-build-jwt-auth \
  httproute grok-build \
  gateway grok-build-gateway \
  agentgatewaybackend xai-grok-build
```

Also drop the `grok-build-gateway` entry from `config/policies/tracing.yaml`
and delete the four YAML files under `config/` if reverting GitOps.
