Control Plane API¶
The control plane is how you create and manage Organizations, Applications, policy, cost configuration, and API keys in G-1 Studio. It is the management counterpart to the chat data plane: the control plane decides what an Application is, the data plane runs requests through it.
Every control-plane route is mounted under /v1/glad — right next to the compliance API — on the same single-origin server as the gateway and the web UI. The routes operate on the shared Studio database (Organizations, Applications, API keys, the usage ledger).
Open locally, gateable in production
On a local or air-gapped install the control plane is open by default — there is nothing to authenticate against, so you can create Applications and keys immediately. As soon as you configure a platform admin token or a single admin API key, every write (create / update / delete) requires authentication. Reads (GET) are never gated. See Authentication & RBAC below.
Call it — create an Application end to end¶
What it does. Creates an Organization, an Application bound to your upstream, sets its policy, and mints the key your application will authenticate with. Four calls; after them the Application is live on the data plane.
BASE=http://localhost:8080
JSON='-H Content-Type:application/json'
ORG=$(curl -s $BASE/v1/glad/orgs $JSON \
-d '{"name":"Acme Legal","max_applications":5}' | jq -r .org_id)
APP=$(curl -s $BASE/v1/glad/apps $JSON -d "{
\"name\": \"Contract Reviewer\",
\"org_id\": \"$ORG\",
\"config\": {\"binding\": {\"upstream_type\":\"ollama\",
\"base_url\":\"http://localhost:11434\",
\"model\":\"llama3.1:8b\"}}
}" | jq -r .app_id)
curl -s -X PUT $BASE/v1/glad/apps/$APP/policy $JSON \
-d '{"policy":{"thresholds":{"prompt_safety":0.75},"scope":"Reviews commercial contracts."}}'
KEY=$(curl -s $BASE/v1/glad/apps/$APP/keys $JSON -d '{"role":"invoke"}' | jq -r .api_key)
echo "$KEY" # shown once — store it now
import httpx
c = httpx.Client(base_url="http://localhost:8080", timeout=30)
# If a platform token is configured:
# c.headers["X-Geodesia-Admin-Key"] = os.environ["GEODESIA_ADMIN_TOKEN"]
org = c.post("/v1/glad/orgs", json={"name": "Acme Legal", "max_applications": 5}).json()
app = c.post("/v1/glad/apps", json={
"name": "Contract Reviewer",
"org_id": org["org_id"],
"config": {"binding": {"upstream_type": "ollama",
"base_url": "http://localhost:11434",
"model": "llama3.1:8b"}},
}).json()
app_id = app["app_id"]
# Ask the server which axes this checkpoint actually has before writing a policy.
meta = c.get("/v1/glad/apps/meta").json()
print("axes:", meta["axes"])
c.put(f"/v1/glad/apps/{app_id}/policy", json={"policy": {
"thresholds": {"prompt_safety": 0.75},
"scope": "Reviews commercial contracts.", # required for the out_of_scope axis
}})
key = c.post(f"/v1/glad/apps/{app_id}/keys", json={"role": "invoke"}).json()
print(key["api_key"]) # returned ONCE — store it now
const BASE = "http://localhost:8080"
const H = { "Content-Type": "application/json" }
const post = (p: string, b: unknown) =>
fetch(BASE + p, { method: "POST", headers: H, body: JSON.stringify(b) }).then(r => r.json())
const org = await post("/v1/glad/orgs", { name: "Acme Legal", max_applications: 5 })
const app = await post("/v1/glad/apps", {
name: "Contract Reviewer",
org_id: org.org_id,
config: { binding: { upstream_type: "ollama", base_url: "http://localhost:11434", model: "llama3.1:8b" } },
})
await fetch(`${BASE}/v1/glad/apps/${app.app_id}/policy`, {
method: "PUT", headers: H,
body: JSON.stringify({ policy: { thresholds: { prompt_safety: 0.75 },
scope: "Reviews commercial contracts." } }),
})
const key = await post(`/v1/glad/apps/${app.app_id}/keys`, { role: "invoke" })
console.log(key.api_key) // returned ONCE — store it now
What comes back — the Application record, and from the last call a key you can immediately use on the data plane:
curl -s http://localhost:8080/gw/v1/chat/completions \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"model":"llama3.1:8b","stream":false,
"messages":[{"role":"user","content":"Is this NDA mutual?"}]}'
Read /v1/glad/apps/meta before writing a policy
It returns the axes the served checkpoint actually has. PUT …/policy rejects any axis it does not know, so building the policy from meta is the difference between a working call and a 400.
Authentication & RBAC¶
The control plane has four roles, in ascending privilege:
A caller's role is resolved from the request headers:
| Header | Role granted | Notes |
|---|---|---|
X-Geodesia-Admin-Key: <token> | platform_admin | Must equal the GEODESIA_ADMIN_TOKEN environment variable. Full access to every Organization and Application. |
Authorization: Bearer <admin app key> | org_admin | An Application API key created with role: "admin". Scoped to the key's Application's Organization. |
Authorization: Bearer <invoke key> | viewer | An Application API key created with role: "invoke" (the default). Read-only on the control plane; its real job is data-plane routing. |
| (none, and nothing configured) | platform_admin | Open mode. When neither GEODESIA_ADMIN_TOKEN is set nor any admin key exists, an anonymous caller is treated as platform_admin. |
| (none, but auth is configured) | viewer | Once a token or an admin key exists, anonymous callers fall back to viewer — they can read but cannot write. |
How open mode closes
The control plane is open only when no GEODESIA_ADMIN_TOKEN is set and no active admin API key exists anywhere in the database. Create one admin key — or set the env token — and writes immediately start requiring authentication. There is no half-open state.
Role required per write¶
Reads are always permitted. Writes require at least the role shown:
| Operation | Minimum role |
|---|---|
Create / update an Application (POST /apps, PUT /apps/{id}) | app_editor |
| Pause / resume / kill an Application | app_editor |
Update policy / cost (PUT /apps/{id}/policy, PUT /apps/{id}/cost) | app_editor |
Delete an Application (DELETE /apps/{id}) | org_admin |
Create an Organization (POST /orgs) | org_admin |
| Create / revoke an API key | org_admin |
An org_admin resolved from an app key may only act on its own Organization — a cross-org write returns 403 org scope mismatch. A platform_admin has no such restriction.
When a write is denied, the API returns:
Route reference¶
Every route, grouped by resource. The Role column is the minimum role for that route (reads are open; the gate only applies once auth is configured).
Meta¶
| Method | Path | Role | Purpose |
|---|---|---|---|
GET | /v1/glad/apps/meta | — | The detection axes the served checkpoint has (axes, extra_axes, supports_axis), the supported-law catalog, and a complete default config — used to render the New Application form and to hide controls for axes the model does not have. |
POST | /v1/glad/apps/upstream/models | — | Discover the models available on an upstream. Live for ollama / vLLM / OpenAI-compatible servers; a curated catalog for Bedrock / Vertex. |
Organizations¶
| Method | Path | Role | Purpose |
|---|---|---|---|
POST | /v1/glad/orgs | org_admin | Create (or upsert) an Organization. |
GET | /v1/glad/orgs | — | List all Organizations. |
GET | /v1/glad/orgs/{org_id}/apps | — | List the Applications in an Organization. |
GET | /v1/glad/orgs/{org_id}/cost/summary | — | Cost breakdown by Application for the Organization. |
GET | /v1/glad/orgs/{org_id}/cost/forecast | — | Projected month-end spend across all the Organization's Applications. |
Applications¶
| Method | Path | Role | Purpose |
|---|---|---|---|
POST | /v1/glad/apps | app_editor | Create an Application. Subject to the entitlement cap and the per-org cap (below). |
GET | /v1/glad/apps | — | List Applications (optional org_id / status filters). |
GET | /v1/glad/apps/{app_id} | — | Fetch one Application (full config). |
PUT | /v1/glad/apps/{app_id} | app_editor | Update name and/or config. Bumps config_version. |
DELETE | /v1/glad/apps/{app_id} | org_admin | Delete an Application and its keys. The default Application cannot be deleted. |
POST | /v1/glad/apps/{app_id}/pause | app_editor | Set status to paused. |
POST | /v1/glad/apps/{app_id}/resume | app_editor | Set status back to active. |
POST | /v1/glad/apps/{app_id}/kill | app_editor | Set status to killed (kill-switch). |
Policy & cost configuration¶
| Method | Path | Role | Purpose |
|---|---|---|---|
GET | /v1/glad/apps/{app_id}/policy | — | The Application's policy block (thresholds, enforcement, etc.). |
PUT | /v1/glad/apps/{app_id}/policy | app_editor | Merge fields into the policy block. |
GET | /v1/glad/apps/{app_id}/cost | — | The Application's cost configuration (rates, budget). |
PUT | /v1/glad/apps/{app_id}/cost | app_editor | Merge fields into the cost configuration. |
GET | /v1/glad/apps/{app_id}/routing | — | The Application's complexity-routing block (Model B binding + threshold). |
PUT | /v1/glad/apps/{app_id}/routing | app_editor | Merge fields into the routing block. A masked or omitted api_key keeps the stored Model-B credential. |
Metrics & cost¶
| Method | Path | Role | Purpose |
|---|---|---|---|
GET | /v1/glad/apps/{app_id}/metrics | — | Call counts from the calls table: total, prompt-blocked, answer-blocked, hallucinated, grounded. |
GET | /v1/glad/apps/{app_id}/cost/summary | — | Month-to-date spend, token totals, blocked count, average cost per call. |
GET | /v1/glad/apps/{app_id}/cost/daily | — | Per-day cost series (for charts). |
GET | /v1/glad/apps/{app_id}/cost/forecast | — | Projected month-end spend (run_rate / moving_avg / linreg / linreg_dow). |
GET | /v1/glad/apps/{app_id}/messages | — | Recent real requests with their per-axis detector probabilities and live decision — the substrate Policy Lens re-decides under a candidate threshold. Optional session_id, limit (≤ 2000). |
GET | /v1/glad/apps/{app_id}/export | app_editor | Full per-Application data export as a .zip of per-table CSV / JSONL. |
GET | /v1/glad/apps/{app_id}/export.sqlite | app_editor | The same export as a standalone SQLite file. |
API keys¶
| Method | Path | Role | Purpose |
|---|---|---|---|
GET | /v1/glad/apps/{app_id}/keys | — | List the Application's keys (preview + metadata only — never the secret). |
POST | /v1/glad/apps/{app_id}/keys | org_admin | Mint a new key. The plaintext is returned exactly once. |
DELETE | /v1/glad/apps/{app_id}/keys/{key_id} | org_admin | Revoke (deactivate) a key. |
Worked examples¶
The examples below assume the server is on http://localhost:8080 and the control plane is open (local install). If you have configured a platform token, add -H "X-Geodesia-Admin-Key: $GEODESIA_ADMIN_TOKEN" to each write.
1. Create an Organization¶
curl -s http://localhost:8080/v1/glad/orgs \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Legal",
"country": "IT",
"license_type": "evaluation",
"max_applications": 5
}'
{
"org_id": "acme_legal",
"name": "Acme Legal",
"country": "IT",
"license_type": "evaluation",
"max_applications": 5,
"created_at": "2026-06-18T09:00:00.000000Z",
"updated_at": "2026-06-18T09:00:00.000000Z"
}
org_id is slugged from name if you omit it. Returns 201 Created. Calling it again with the same org_id upserts the name/country.
2. Create an Application¶
curl -s http://localhost:8080/v1/glad/apps \
-H "Content-Type: application/json" \
-d '{
"name": "Contract Reviewer",
"org_id": "acme_legal",
"config": {
"binding": {"upstream_type": "ollama", "base_url": "http://localhost:11434", "model": "llama3.1:8b"},
"calibration_profile": "llama3.1:8b"
}
}'
{
"app_id": "contract_reviewer_4f2a9c",
"config_version": 1,
"status": "active",
"app": {
"app_id": "contract_reviewer_4f2a9c",
"org_id": "acme_legal",
"name": "Contract Reviewer",
"status": "active",
"config_version": 1,
"config": { "schema_version": 1, "binding": { "...": "..." }, "policy": { "...": "..." } }
}
}
If you omit config, the Application is created with the complete default config (every axis the served checkpoint has, at its serving-calibrated threshold). Returns 201 Created. An invalid config returns 400 with the validation message.
The free-tier Application cap
Application creation is subject to a global, license-driven entitlement cap. On the free tier the limit is 1 Application (the seeded default Application already counts toward it). When you exceed it, POST /apps returns 403:
{
"detail": "application limit reached (free tier: 1 application(s)). Add a license token to create more."
}
A signed license token raises (or removes) the cap. A separate per-Organization cap (max_applications on the Org) also applies — exceeding it returns 403 max_applications reached for org (license limit). The default Organization is exempt from the per-org cap. See Licensing.
3. Set the Application's policy¶
Policy updates are a merge — you send only the fields you want to change.
curl -s -X PUT http://localhost:8080/v1/glad/apps/contract_reviewer_4f2a9c/policy \
-H "Content-Type: application/json" \
-d '{
"policy": {
"thresholds": {"prompt_safety": 0.75, "jailbreak": 0.55},
"enforcement": {"answer_safety": "block"},
"block_input": true
}
}'
{
"policy": {
"thresholds": {"prompt_safety": 0.75, "jailbreak": 0.55, "rag_jailbreak": 0.2501,
"halluc_context": 0.6475, "halluc_closedbook": 0.58, "answer_safety": 0.7295,
"profanity": 0.90, "out_of_scope": 0.90, "prompt_complexity": 0.50},
"enforcement": {"prompt_safety": "block", "jailbreak": "block", "rag_jailbreak": "block",
"halluc_context": "annotate", "halluc_closedbook": "annotate", "answer_safety": "block",
"profanity": "annotate", "out_of_scope": "annotate", "prompt_complexity": "off"},
"block_input": true
},
"config_version": 2
}
The cost configuration works the same way via PUT /apps/{id}/cost (merge currency, input_per_mtok, output_per_mtok, budget_month, on_budget_exceeded, …). The full policy/cost field shape is documented in Managing Applications.
4. Mint and use an API key¶
curl -s http://localhost:8080/v1/glad/apps/contract_reviewer_4f2a9c/keys \
-H "Content-Type: application/json" \
-d '{"role": "invoke"}'
{
"key_id": "ak_9b1f2c3d",
"app_id": "contract_reviewer_4f2a9c",
"key_preview": "g1k_***x8Qv",
"role": "invoke",
"api_key": "g1k_live_8sQ3...x8Qv"
}
Shown once
The plaintext api_key is returned only on creation — only its SHA-256 hash is stored. Copy it now; you cannot retrieve it later. GET /keys returns the preview and metadata, never the secret.
The key is the Application's runtime identity on the data plane. Pass it as a Bearer token on a chat request to route that request through this Application:
curl -s http://localhost:8080/gw/v1/chat/completions \
-H "Authorization: Bearer g1k_live_8sQ3...x8Qv" \
-H "Content-Type: application/json" \
-d '{"model":"llama3.1:8b","stream":false,"messages":[{"role":"user","content":"Summarise clause 4."}]}'
A role: "admin" key additionally acts as an org_admin on the control plane (see RBAC).
5. Read metrics and forecast cost¶
{
"application_id": "contract_reviewer_4f2a9c",
"total": 1284,
"prompt_blocked": 17,
"answer_blocked": 5,
"hallucinated": 31,
"grounded": 1102
}
{
"period": "2026-06",
"currency": "EUR",
"spent_mtd": 42.18,
"days_elapsed": 18,
"days_in_month": 30,
"projected_month": 71.94,
"budget": 100.0,
"projected_pct": 0.7194,
"over_budget": false,
"method": "linreg",
"ci80": [61.40, 82.48]
}
The forecast is deterministic and numpy-only. run_rate (the default) extrapolates the month-to-date average; moving_avg uses the trailing 7 days; linreg / linreg_dow fit a trend (the latter applies a day-of-week factor) and return an 80% confidence band in ci80. Pass budget via the Application's cost config — the route reads budget_month from it. The summary endpoint (/cost/summary) returns the same period's token totals and avg_cost_per_call.
Data-plane routing header¶
The control plane defines Applications; the data plane selects one per request (_resolve_app). A chat or RAG request picks its Application via either an explicit identifier or an Application API key, in this order:
- Explicit id / header — the body field
application_id(aliasapp_id), or theX-Geodesia-App: <app_id>request header. An explicit id always wins, including an explicitdefault. - Application API key — an
invoke(oradmin) key sent asAuthorization: Bearer g1k_live_…, used only as a fallback when no explicit id/header is present. The gateway verifies the key and routes the request to the Application that key belongs to.
The gateway resolves that identifier to the Application's config — its upstream binding, block_input, and per-axis thresholds are merged into the request. An unknown identifier, or the literal default, falls back to the global / default configuration, so existing single-upstream integrations keep working unchanged.
Only g1k_-prefixed bearers are looked up
When resolving from a Bearer token, the gateway considers only tokens that begin with g1k_. Any other Bearer — including the gateway's own GW_API_TOKEN — is ignored for app resolution, so it does not accidentally route a request. A revoked, expired, or unknown g1k_ key does not error: it simply falls through to the default Application.
Route via the header (or application_id in the body):
curl -s http://localhost:8080/gw/v1/chat/completions \
-H "X-Geodesia-App: contract_reviewer_4f2a9c" \
-H "Content-Type: application/json" \
-d '{"model":"llama3.1:8b","stream":false,"messages":[{"role":"user","content":"Is this NDA mutual?"}]}'
Route via an Application API key (no header needed):
curl -s http://localhost:8080/gw/v1/chat/completions \
-H "Authorization: Bearer g1k_live_8sQ3...x8Qv" \
-H "Content-Type: application/json" \
-d '{"model":"llama3.1:8b","stream":false,"messages":[{"role":"user","content":"Is this NDA mutual?"}]}'
The key belongs to exactly one Application, so it resolves the Application implicitly — a raw API client that authenticates with just Authorization: Bearer g1k_live_… is still routed to, scoped to, and billed against its own Application. If you send both an explicit id/header and a g1k_ key, the explicit id/header wins. For the full chat request/response contract, see the Chat API.
See also¶
- Managing Applications — the Application config shape (binding, policy, cost, governance) and lifecycle.
- Licensing — the entitlement cap, license tokens, and how they raise the Application limit.
- Cost & FinOps — the usage ledger, daily roll-up, budgets, and forecasting model.
- Chat API — the data-plane endpoint that consumes the routing header and the per-Application policy.