Skip to content

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:

viewer  <  app_editor  <  org_admin  <  platform_admin

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:

{ "detail": "requires role >= org_admin (you are viewer)" }

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

curl -s http://localhost:8080/v1/glad/apps/contract_reviewer_4f2a9c/metrics
{
  "application_id": "contract_reviewer_4f2a9c",
  "total": 1284,
  "prompt_blocked": 17,
  "answer_blocked": 5,
  "hallucinated": 31,
  "grounded": 1102
}
curl -s "http://localhost:8080/v1/glad/apps/contract_reviewer_4f2a9c/cost/forecast?method=linreg"
{
  "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:

  1. Explicit id / header — the body field application_id (alias app_id), or the X-Geodesia-App: <app_id> request header. An explicit id always wins, including an explicit default.
  2. Application API key — an invoke (or admin) key sent as Authorization: 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.