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: trueemails the user a setup link (requires an email and configured SMTP, else400); the user sets their own password.passwordgiven: used as-is.passwordomitted 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.