API Reference

All endpoints are served under /api. Requests and responses are JSON.

Authentication

CityHall uses server-side sessions. A successful POST /api/auth/login sets an HttpOnly session cookie (cityhall_session); send it on subsequent requests. Browsers do this automatically; from a client, use a cookie jar (e.g. curl -c jar -b jar).

Every endpoint except GET /api/health and POST /api/auth/login requires a valid session and returns 401 Unauthorized without one.

Forced password change

While a user’s must_change_password flag is set, all user-management endpoints return 403 Forbidden with {"error":"password change required"}. Only GET /api/auth/me, POST /api/auth/change-password, and POST /api/auth/logout are permitted until the password is changed.

Authorization (RBAC)

Every user has a role, and a role holds a set of permission keys (the wildcard * grants all). Endpoints require a specific permission; a caller lacking it gets 403 Forbidden with {"error":"insufficient permissions"}. The current keys are users.read, users.write, roles.read, roles.write, settings.read, settings.write, workspaces.use, workspaces.read, workspaces.write, and dashboard.read. GET /api/auth/me returns the caller’s effective permission list so a client can gate its UI. Built-in roles are admin (all permissions) and member (users.read, workspaces.use).

Errors

Errors use HTTP status codes with a JSON body:

{ "error": "human-readable message" }

Common codes: 400 (bad request), 401 (unauthenticated), 403 (password change required or insufficient permissions), 404 (not found), 409 (conflict, e.g. duplicate username).

Endpoints

GET /api/health

Liveness check. Returns 200 OK with the body ok. No authentication.

POST /api/auth/login

{ "username": "admin", "password": "..." }

On success, sets the session cookie and returns:

{ "must_change_password": true }

Invalid credentials return 401. A self-signup account that has not yet verified its email returns 403.

POST /api/auth/logout

Clears the session (server-side and cookie). Returns 200.

GET /api/auth/me

Returns the current user, including their role and effective permissions:

{
  "id": 1,
  "username": "admin",
  "email": null,
  "must_change_password": false,
  "role_id": 1,
  "role": "admin",
  "permissions": ["users.read", "users.write", "roles.read", "roles.write", "settings.read", "settings.write"],
  "onboarding_dismissed": false
}

POST /api/auth/change-password

{ "current_password": "...", "new_password": "..." }

Verifies the current password and requires the new one to be at least 8 characters. Returns the updated user (with must_change_password: false).

POST /api/me/onboarding-dismissed

Any authenticated session (no permission required, like /auth/change-password). Marks the caller’s own one-time onboarding UI as dismissed and returns { "onboarding_dismissed": true }. Idempotent.

POST /api/auth/forgot-password

Public (no authentication).

{ "email": "user@example.com" }

Always returns 200 regardless of whether the email matches an account, so it cannot be used to enumerate addresses. When it matches a user and SMTP is configured, a single-use reset link (valid 1 hour) is emailed. Requires SMTP to be configured (see Configuration); the link’s base URL comes from CITYHALL_BASE_URL or the request host.

POST /api/auth/reset-password

Public (no authentication).

{ "token": "...", "new_password": "..." }

Redeems a reset or setup token and sets the new password (minimum 8 characters), clearing must_change_password. The token is single-use; an unknown, expired, or already-used token returns 400.

GET /api/auth/providers

Public (no authentication). Reports which login methods are available so the login page can render accordingly:

{ "oidc": true, "signup": false }

oidc is true when OIDC SSO is configured and enabled; signup is true when self-signup is enabled.

GET /api/auth/oidc/login

Public. Begins the OIDC flow: sets a short-lived flow cookie (CSRF state, nonce, PKCE verifier) and 303-redirects the browser to the identity provider. Returns 400 if SSO is not configured. Open it as a top-level navigation, not via fetch.

GET /api/auth/oidc/callback

Public. The provider’s redirect target. Exchanges the code, verifies the ID token, links or provisions the user (see Configuration), starts a session, and 303-redirects to /. Linking to an existing account always works; creating a new account on first login requires self-signup to be enabled, otherwise it redirects to /login?error=... (as with any failure).

POST /api/auth/register

Public. Only works when self-signup is enabled (see Configuration), else 400.

{ "username": "bob", "email": "bob@example.com", "password": "..." }

Creates an unverified account and emails a verification link (SMTP must be configured). Password must be at least 8 characters. Returns 400 if signup is disabled, the email domain is not allowed, or SMTP is unconfigured; 409 if the username or email is already taken. On success returns 200; the account cannot log in until verified.

POST /api/auth/verify-email

Public.

{ "token": "..." }

Redeems a verification token and marks the account verified so it can log in. The token is single-use; an unknown, expired, or used token returns 400.

GET /api/users

Requires users.read. Returns all users:

[
  { "id": 1, "username": "admin", "email": null, "must_change_password": false, "created_at": "2026-07-07T09:20:50Z", "role_id": 1 }
]

Password hashes are never included in any response.

POST /api/users

Requires users.write.

{ "username": "bob", "email": "bob@example.com", "password": "...", "send_setup_email": false, "role_id": 2 }

email may be omitted or null. password, send_setup_email, and role_id are optional; role_id defaults to the member role. Behavior:

  • send_setup_email: true emails the user a setup link (requires an email and configured SMTP, else 400); the user sets their own password.
  • password given: used as-is.
  • password omitted or empty: a password is generated and the user must change it on first login.

Returns the created user plus generated_password, which is the generated password when one was generated and null otherwise (including when a setup email was sent):

{ "id": 2, "username": "bob", "email": null, "must_change_password": true, "created_at": "...", "generated_password": "..." }

A duplicate username returns 409.

GET /api/users/{id}

Returns a single user, or 404 if not found.

PATCH /api/users/{id}

Requires users.write. Partial update; every field is optional:

{ "username": "bob2", "email": "new@example.com", "password": "...", "role_id": 3 }

Renaming to an existing username returns 409; an unknown role_id returns 400. Returns the updated user.

DELETE /api/users/{id}

Requires users.write. Deletes a user. Deleting your own account returns 400. Returns:

{ "deleted": true }

GET /api/permissions

Requires roles.read. Returns the permission-key catalog for building a role editor:

[
  { "key": "users.read", "description": "View users" },
  { "key": "users.write", "description": "Create, edit, and delete users" }
]

GET /api/roles

Requires roles.read. Returns all roles. permissions is the array of keys (["*"] for the wildcard); user_count is how many users hold the role.

[
  {
    "id": 1,
    "name": "admin",
    "description": "Full access to everything",
    "permissions": ["*"],
    "is_system": true,
    "created_at": "2026-07-07T09:20:50Z",
    "user_count": 1
  }
]

POST /api/roles

Requires roles.write.

{ "name": "editor", "description": "Manage users", "permissions": ["users.read", "users.write"] }

description may be omitted or null. Unknown permission keys return 400; a duplicate name returns 409. Returns the created role.

PATCH /api/roles/{id}

Requires roles.write. Partial update (name, description, permissions). The built-in admin role cannot be modified (403); a built-in role cannot be renamed (403). Unknown permission keys return 400. Returns the updated role.

DELETE /api/roles/{id}

Requires roles.write. Deletes a role. Built-in roles cannot be deleted (403), and a role still assigned to users cannot be deleted (409). Returns { "deleted": true }.

GET /api/settings/smtp

Requires settings.read. Returns the effective SMTP configuration. The password itself is never returned, only whether one is stored (password_set).

{
  "env_managed": false,
  "enabled": true,
  "host": "smtp.example.com",
  "port": 587,
  "encryption": "starttls",
  "username": "cityhall",
  "from_address": "cityhall@example.com",
  "from_name": "CityHall",
  "password_set": true,
  "secret_key_available": true
}

env_managed is true when SMTP is configured through environment variables (see Configuration); the values then come from the environment and PUT is rejected. secret_key_available reflects whether CITYHALL_SECRET_KEY is set, which is required to store a password.

PUT /api/settings/smtp

Requires settings.write. Updates the stored SMTP configuration:

{
  "host": "smtp.example.com",
  "port": 587,
  "encryption": "starttls",
  "username": "cityhall",
  "password": "...",
  "from_address": "cityhall@example.com",
  "from_name": "CityHall",
  "enabled": true
}

encryption is one of none, starttls, tls. username, password, and from_name may be omitted or null. Omitting password keeps the stored one; sending a value replaces it. Storing a password requires CITYHALL_SECRET_KEY to be set, otherwise the request returns 400. When SMTP is env-managed, this returns 409. Returns the updated settings (same shape as GET).

POST /api/settings/smtp/test

Requires settings.write.

{ "to": "you@example.com" }

Sends a test email using the effective configuration. Always returns 200 with the result, so a delivery failure surfaces the provider’s message rather than an HTTP error:

{ "ok": false, "error": "send failed: connection refused" }

Returns 400 if SMTP is not configured or not enabled.

GET /api/settings/oidc

Requires settings.read. Returns the OIDC configuration. The client secret is never returned, only whether one is stored (client_secret_set).

{
  "env_managed": false,
  "enabled": true,
  "issuer": "https://accounts.example.com",
  "client_id": "cityhall",
  "scopes": "openid email profile",
  "allowed_domains": "example.com",
  "client_secret_set": true,
  "secret_key_available": true,
  "callback_path": "/api/auth/oidc/callback"
}

env_managed is true when OIDC is configured through environment variables; the values then come from the environment and PUT is rejected. Register {base_url}{callback_path} with the identity provider.

PUT /api/settings/oidc

Requires settings.write. Updates the stored OIDC configuration:

{
  "enabled": true,
  "issuer": "https://accounts.example.com",
  "client_id": "cityhall",
  "client_secret": "...",
  "scopes": "openid email profile",
  "allowed_domains": "example.com"
}

issuer and client_id are required. Omitting client_secret keeps the stored one; sending a value replaces it (and requires CITYHALL_SECRET_KEY, else 400). allowed_domains is comma-separated (empty allows any domain). When OIDC is env-managed, this returns 409. Returns the updated settings.

GET /api/settings/signup

Requires settings.read. Returns the self-signup configuration.

{ "signup_enabled": false, "signup_allowed_domains": "example.com", "signup_default_role_id": null }

signup_default_role_id is the role assigned to new sign-ups; null means the member role.

PUT /api/settings/signup

Requires settings.write. Updates the self-signup configuration (same shape as GET). signup_allowed_domains is comma-separated (empty allows any domain); an unknown signup_default_role_id returns 400. Enabling signup while SMTP is unconfigured returns 400 (verification email cannot be sent). Returns the updated settings.

GET /api/settings/setup

Requires settings.read. The admin setup checklist/wizard’s persisted state: which steps have been dismissed as handled or not needed, and whether the wizard itself has been finished. No row yet reads as nothing dismissed and the wizard not finished.

{ "dismissed_steps": ["password", "email"], "wizard_finished": false }

dismissed_steps is one or more of password, version, agents, projects, email, sso, invites.

PUT /api/settings/setup

Requires settings.write. Replaces the checklist state (same shape as GET) and returns it back. An unknown step key in dismissed_steps returns 400 and nothing is stored; the stored set is deduplicated and reordered into a canonical order, so saving the same set again (in any order, with repeats) reads as no change.

GET /api/dashboard

Requires dashboard.read, which also exposes CityHall’s own system metrics and not only workspace state. Unrelated to the aoe telemetry policy in Workspaces, which governs what workspaces report upstream.

Served from a snapshot a background task refreshes every 10 seconds, so the figures are near-real-time rather than instantaneous and the endpoint costs the same however many workspaces exist. sampled_at is null until the first sample completes; stale means the sampler looks stuck rather than merely between ticks. errors lists sources that failed on the last attempt, and the values beside them are the last ones collected successfully.

usage is per-workspace CPU and memory, keyed by user_id, and is only populated on the docker backend; usage_supported is false where the backend has no metrics source, which is different from nothing running. cpu_percent is a share of one CPU, so a workspace using two cores reports 200.

system describes the machine as CityHall sees it, which is not necessarily the machine running the workspaces: scope is host or cityhall_container. cgroup_memory is CityHall’s own limit when it is narrower than the system’s, and never replaces the totals. disk is null unless SYSTEM_METRICS_DISK_PATH is configured (see Configuration).

{
  "sampled_at": "2026-08-04T18:44:11Z",
  "stale": false,
  "errors": [],
  "system": {
    "scope": "host",
    "cpu_percent": 40.2,
    "cpu_count": 12,
    "memory_used_bytes": 25886179328,
    "memory_total_bytes": 34359738368,
    "cgroup_memory": null,
    "disk": { "path": "/", "mount_point": "/", "used_bytes": 973773684736, "total_bytes": 994662584320 }
  },
  "usage_supported": true,
  "usage": [{ "user_id": 2, "cpu_percent": 12.3, "memory_bytes": 1572864, "memory_limit_bytes": 8160437862 }],
  "workspaces": [
    {
      "user_id": 2,
      "username": "bob",
      "status": "running",
      "effective_version": "v0.5.0",
      "running_version": "v0.5.0",
      "last_active_at": "2026-07-15T09:20:50Z",
      "provisioning": null
    }
  ],
  "summary": {
    "total_users": 1,
    "running": 1,
    "stopped": 0,
    "not_created": 0,
    "unknown": 0,
    "provisioning": 0,
    "usage_available": 1
  },
  "versions": [{ "version": "v0.5.0", "count": 1 }]
}

GET /api/me/dashboard-layout

Requires dashboard.read. The caller’s saved dashboard widget arrangement, or {"layout": null} when they have never customized it. A stored layout from an incompatible schema version reads as null rather than failing.

PUT /api/me/dashboard-layout

Requires dashboard.read. Saves the caller’s own arrangement and returns 204.

{
  "schema_version": 1,
  "items": [{ "id": "system-usage", "x": 0, "y": 0, "w": 6, "h": 7 }],
  "hidden": ["versions"]
}

At most 32 items and 32 hidden ids, widget ids matching [a-z][a-z0-9-]{0,63}, no duplicates, and x + w within the 12-column grid; anything else returns 400. Concurrent saves are last-write-wins.

GET /api/workspaces

Requires workspaces.read. Returns every user with their workspace state (see Workspaces). status is not_created, stopped, running, or unknown (runtime unreachable); effective_version is the pin or the default.

[
  {
    "user_id": 2,
    "username": "bob",
    "status": "running",
    "pinned_version": null,
    "effective_version": "v0.5.0",
    "last_active_at": "2026-07-15T09:20:50Z"
  }
]

GET /api/workspaces/me

Requires workspaces.use. The caller’s own workspace plus the origin browsers use to reach the workspace proxy:

{
  "status": "stopped",
  "pinned_version": null,
  "effective_version": "v0.5.0",
  "proxy_origin": "http://127.0.0.1:3001"
}

POST /api/workspaces/{user_id}/start

Requires workspaces.write. Starts (or resumes) the user’s workspace. Returns 503 with a descriptive error when the runtime or image is unavailable.

POST /api/workspaces/{user_id}/stop

Requires workspaces.write. Stops the workspace, keeping its data volume.

POST /api/workspaces/me/restart

Requires workspaces.use. Recreates the caller’s own workspace if it is running, keeping its data volume, and returns 204. This is how a user applies an agent credential they just saved: credentials are part of a container’s environment, so a change only takes effect when the container is next created. A stopped workspace is left stopped and picks the change up on its next start. Any agent session running inside the workspace ends.

DELETE /api/workspaces/{user_id}

Requires workspaces.write. Destroys the workspace AND its data volume. Returns { "destroyed": true }.

PATCH /api/workspaces/{user_id}

Requires workspaces.write. Pins the served aoe version (null or empty unpins, following the default). A running workspace is recreated with the new image on its next start or proxied request; its volume is kept.

{ "pinned_version": "v0.5.1" }

PATCH /api/workspaces

Requires workspaces.write. Grouped pin: same as above for several users in one call.

{ "user_ids": [2, 3], "pinned_version": "v0.5.1" }

GET /api/settings/workspaces

Requires settings.read.

agents is the coding agents a workspace is set up to arrive with, and available_agents is the whole selectable catalog, so a client lists that rather than hardcoding one.

telemetry_policy is the stored policy verbatim, while telemetry_policy_override is set only when WORKSPACE_TELEMETRY_POLICY pins one for the deployment, and effective_telemetry_policy is what workspaces actually run under.

{
  "image_template": "cityhall/aoe:{version}",
  "default_version": "v0.5.0",
  "idle_stop_minutes": 30,
  "telemetry_policy": "user_choice",
  "telemetry_policy_override": null,
  "effective_telemetry_policy": "user_choice",
  "agents": ["claude"],
  "available_agents": [
    { "name": "claude", "label": "Claude" },
    { "name": "codex", "label": "Codex" },
    { "name": "gemini", "label": "Gemini" },
    { "name": "opencode", "label": "OpenCode" }
  ]
}

PUT /api/settings/workspaces

Requires settings.write. Takes image_template, default_version, idle_stop_minutes, telemetry_policy, agents, and restart_running. The three fields the server derives, telemetry_policy_override, effective_telemetry_policy, and available_agents, are not settings and are ignored if sent.

image_template is required; idle_stop_minutes must be at least 1. A name in agents that is not in the catalog is rejected with 400 and nothing is stored.

agents is optional, and the three cases differ: omitting it preserves the stored set, [] clears it, and a list replaces it. Omission preserves rather than clears so a client that predates the field cannot wipe the selection just by saving the idle timeout.

GET /api/me/agent-credentials

Requires workspaces.use. The provider credentials forwarded into the caller’s workspace. Always the whole supported set, whether or not a value is stored, so a client renders this list rather than hardcoding one. A stored value is never returned.

{
  "secret_key_available": true,
  "credentials": [
    {
      "env_var": "ANTHROPIC_API_KEY",
      "label": "Anthropic API key (Claude)",
      "structured_view": true,
      "limitation": null,
      "value_set": true,
      "usable": true
    },
    {
      "env_var": "OPENAI_API_KEY",
      "label": "OpenAI API key (Codex)",
      "structured_view": false,
      "limitation": "Available in terminal sessions only. Structured-view agents do not receive this variable yet.",
      "value_set": false,
      "usable": false
    }
  ]
}

value_set is whether a value is stored; usable is whether it still decrypts as this user’s credential for this variable, so value_set true with usable false means either a CITYHALL_SECRET_KEY change without a rotation, or a value that does not belong to this row. limitation is non-null for variables that reach terminal sessions but not structured-view agents. secret_key_available false means nothing can be stored at all.

PUT /api/me/agent-credentials/{env_var}

Requires workspaces.use. Stores or replaces one value and returns the same shape as GET. env_var must be one of the supported variables, otherwise 400. An empty value is rejected rather than treated as “keep the existing one”; delete the credential to remove it.

{ "value": "sk-..." }

The change takes effect when the workspace is next created, not immediately. See POST /api/workspaces/me/restart.

DELETE /api/me/agent-credentials/{env_var}

Requires workspaces.use. Removes the credential and returns the same shape as GET. Idempotent: deleting one that was never stored succeeds.

GET /api/users/{user_id}/agent-credentials

Requires workspaces.write. Same shape as GET /api/me/agent-credentials, for another user, so an admin can see which credentials a user has before handing their workspace over. Values are not returned here either.

PUT /api/users/{user_id}/agent-credentials/{env_var}

Requires workspaces.write. Sets a credential on another user’s behalf, which is how a workspace can be provisioned ready to use. Same body and rules as the me route. Returns 404 for an unknown user. Every write is recorded in the server log with the acting and target user.

DELETE /api/users/{user_id}/agent-credentials/{env_var}

Requires workspaces.write. Removes another user’s credential.