Pronounced "adams".
PostgREST-style HTTP API for PostgreSQL and MySQL, plus an optional bundled admin UI — all in one binary.
Status: the read / write API and the bundled admin UI both ship, including CSV / JSON export, keyboard shortcuts, an a11y pass, and a schema ER diagram.
Point adms at a database and you get two ways in: an HTTP API the frontend can call directly, and an optional
browser-based admin UI hosted from the same binary. No service layer, no codegen, no schema duplicated in two places.
# adms.yaml
driver: postgres
dsn: "${ADMS_DSN}"ADMS_DSN="postgres://postgres@localhost/myapp?sslmode=disable" adms# list active users, newest first, with their three latest posts embedded
curl 'http://localhost:7777/users?status=eq.active&order=created_at.desc&limit=10&select=id,name,posts(id,title)'[
{
"id": 42,
"name": "alice",
"posts": [
{
"id": 1001,
"title": "Hello"
},
{
"id": 998,
"title": "Notes on B-trees"
}
]
}
]# adms.yaml
driver: postgres
dsn: "${ADMS_DSN}"
ui:
enabled: trueADMS_DSN="postgres://postgres@localhost/myapp?sslmode=disable" adms
# → open http://localhost:7778/You land on an admin console (light or dark, following your OS) with schema-grouped tables in the sidebar, sortable /
filterable / pageable row views in the main pane, FK-aware embedded rows, inline editing, typed insert forms, CSV / JSON
export, a built-in schema viewer, and a whole-schema ER diagram. No node_modules, no separate deploy — the UI is
embedded in the binary.
The same idea drives both surfaces: reads, writes, joins, ordering, paging, counting — all defined by your database schema.
Two friction points in every admin tool you have ever built:
- The backend is generic, but you keep writing it. List endpoints with filters, sorting, paging, related rows, CRUD — the shape is already in the database schema, but every project hand-writes it again.
- The frontend is generic too, eventually. Once the API exists, the dashboard becomes "tables with filters and forms" yet again. Spinning up a React app, picking a component library, and wiring it up is a separate, parallel project.
adms collapses both into a single binary. It introspects your database on startup and exposes
a PostgREST-style HTTP API automatically. Set ui.enabled: true in the config and the same
binary serves a complete admin frontend — no extra deploy, no separate codebase.
The closest neighbor is PostgREST itself: excellent, but PostgreSQL only and API only. adms aims for PostgreSQL +
MySQL and API + (optional) UI, with no extra dependencies to install beyond the binary.
Prebuilt binaries for macOS, Linux, and Windows are attached to each
release. Install via Homebrew or go install:
# Homebrew (tap)
brew install mickamy/tap/adms
# go install
go install github.com/mickamy/adms@latestOr build from source:
git clone https://github.com/mickamy/adms
cd adms
make build
./bin/adms --versionadms reads a YAML or TOML config file. With no positional argument it auto-detects adms.yaml, adms.yml, then
adms.toml in the current directory; otherwise pass the path explicitly (e.g., adms /etc/adms.yaml). Strings in the
config are expanded from the environment via ${VAR} / $VAR, so secrets stay out of the file.
# adms.yaml
driver: postgres
dsn: "${ADMS_DSN}"ADMS_DSN="postgres://postgres@localhost:5432/myapp?sslmode=disable" adms# adms.yaml
driver: mysql
dsn: "${ADMS_DSN}"ADMS_DSN="user:pass@tcp(localhost:3306)/myapp?parseTime=true" admsOn boot, adms introspects the target database, builds an in-memory schema model, and starts listening on :7777
(override with listen: in the config). Every introspected table becomes a resource at /<table_name>. With
ui.enabled: true, a second listener on :7778 (override with ui.listen:) also serves the bundled admin UI from
the same process.
Verify it works:
curl http://localhost:7777/ # schema dump (JSON)
curl http://localhost:7777/healthz # → "ok"
curl http://localhost:7777/<some_table> # first 100 rows as JSONGET /<table> returns rows. Everything else — filtering, projection, ordering, paging, embedding — is driven by URL
query parameters. Writes use POST / PATCH / DELETE with JSON bodies. The shape mirrors PostgREST so existing
clients and mental models transfer.
A filter has the form ?<column>=<op>.<value>. Multiple filters are AND-combined.
| Operator | Example | SQL equivalent |
|---|---|---|
eq |
status=eq.active |
status = 'active' |
gt / gte |
age=gte.18 |
age >= 18 |
ilike |
name=ilike.AL* |
name ILIKE 'AL%' (MySQL: case-insensitive via LOWER()) |
in |
id=in.(1,2,3) |
id IN (1, 2, 3) |
is |
deleted_at=is.null |
deleted_at IS NULL |
like |
name=like.al* |
name LIKE 'al%' |
lt / lte |
score=lt.100 |
score < 100 |
neq |
status=neq.banned |
status <> 'banned' |
not |
status=not.eq.banned |
NOT (status = 'banned') |
Wildcards in like / ilike use * (translated to %); _ remains a single-character wildcard.
curl 'http://localhost:7777/users?status=eq.active&age=gte.18&deleted_at=is.null'By default, every column is returned. Use select to pick columns:
curl 'http://localhost:7777/users?select=id,name,email'Use * to mean "all columns of this row":
curl 'http://localhost:7777/users?select=*,created_at'adms reads foreign keys from the schema and lets you embed related rows by table name in parentheses:
# user → posts (one-to-many via posts.user_id → users.id)
curl 'http://localhost:7777/users?id=eq.1&select=id,name,posts(id,title,created_at)'# post → author (many-to-one), with an alias
curl 'http://localhost:7777/posts?select=*,author:users(id,name)'Embeds nest:
curl 'http://localhost:7777/users?select=*,posts(id,title,comments(id,body))'Embedded relations resolve to JSON arrays for one-to-many, and JSON objects for many-to-one, derived from the FK direction.
curl 'http://localhost:7777/users?order=created_at.desc,id.asc&limit=20&offset=40'limit defaults to 100 when omitted and is capped at 1000.
To get a total count alongside the page, send Prefer: count=exact:
curl -i -H 'Prefer: count=exact' 'http://localhost:7777/users?limit=20'HTTP/1.1 200 OK
Content-Range: 0-19/1342
Content-Type: application/json
All write methods accept JSON bodies (Content-Type: application/json assumed).
curl -X POST http://localhost:7777/users \
-H 'Content-Type: application/json' \
-d '{"name": "carol", "status": "active"}'HTTP/1.1 201 Created
Location: /users?id=eq.42
curl -X POST http://localhost:7777/users \
-H 'Content-Type: application/json' \
-d '[{"name": "dave"}, {"name": "eve"}]'PATCH requires at least one filter — adms rejects an unfiltered PATCH with 400 Bad Request to prevent accidental
table-wide updates.
curl -X PATCH 'http://localhost:7777/users?id=eq.1' \
-H 'Content-Type: application/json' \
-d '{"status": "inactive"}'Same rule as PATCH: a filter is mandatory.
curl -X DELETE 'http://localhost:7777/users?id=eq.1'| Value | Effect |
|---|---|
count=exact |
Content-Range header with total row count |
return=minimal (default for writes) |
Empty body, Location header for inserts |
return=representation |
Body contains the affected rows |
curl -X POST http://localhost:7777/users \
-H 'Content-Type: application/json' \
-H 'Prefer: return=representation' \
-d '{"name": "frank"}'{
"id": 43,
"name": "frank",
"status": null,
"created_at": "2026-05-21T08:12:00Z"
}Errors follow a PostgREST-shaped JSON envelope with adms-specific codes (prefixed ADMS_):
{
"code": "ADMS_UNKNOWN_COLUMN",
"message": "column \"foo\" does not exist in table \"users\"",
"details": null,
"hint": "available columns: id, name, status, created_at"
}| HTTP | code | When |
|---|---|---|
| 400 | ADMS_INVALID_FILTER |
Bad operator or value format |
| 400 | ADMS_UNFILTERED_WRITE |
PATCH / DELETE without any filter |
| 400 | ADMS_UNKNOWN_COLUMN |
Column name not in schema |
| 403 | ADMS_READ_ONLY |
Write attempted while read_only: true |
| 404 | ADMS_UNKNOWN_TABLE |
Table name not in (allowed) schema |
| 409 | ADMS_CONFLICT |
DB-level unique / FK violation |
| 422 | ADMS_INVALID_BODY |
JSON body fails column-type validation |
| 500 | ADMS_INTERNAL |
Anything unexpected |
GET / returns the introspected schema as JSON. A frontend (yours or the bundled admin UI) uses this to render forms,
infer column types, and discover relations without bundling a schema of its own.
curl http://localhost:7777/{
"tables": [
{
"schema": "public",
"name": "users",
"primary_key": [
"id"
],
"columns": [
{
"name": "id",
"type": "bigint",
"nullable": false,
"default": "nextval(...)"
},
{
"name": "name",
"type": "text",
"nullable": false
},
{
"name": "status",
"type": "text",
"nullable": true
},
{
"name": "created_at",
"type": "timestamptz",
"nullable": false,
"default": "now()"
}
],
"foreign_keys": [],
"referenced_by": [
{
"table": "posts",
"columns": [
"user_id"
],
"references": [
"id"
]
}
]
}
]
}Enabled with ui.enabled: true in the config. Off by default, so API-only deployments stay lean. When on, the UI is
served on a separate listener (ui.listen, default :7778) by the same process. The API at :7777 stays
untouched, with table names occupying the full URL root. The UI calls the same HTTP API documented above, with CORS
auto-configured between the two listeners — it is not a parallel implementation, it is the first-class client of it.
The UI is a single-binary affair: HTML, CSS, and JavaScript are embedded into the adms executable via embed.FS —
including the tree-shaken minified Tailwind CSS bundle, so deployments in closed networks need no external CDN access.
No node_modules, no separate frontend deploy. It is rendered server-side with Go's html/template and made
interactive with vanilla fetch against the same HTTP API documented above.
- Sidebar — schema-grouped table list with incremental search.
- Table view — row list with PostgREST-style filter inputs (kind-aware placeholders, bare values auto-prefixed with the kind-default operator), column-header sort, paging, FK arrows that jump to the referenced row, and CSV / JSON export of the current filtered query.
- Keyboard —
Cmd/Ctrl+Kopens a fuzzy table palette;↑/↓move the row selection andEnteropens the highlighted row. - Row detail — type-aware inputs by column kind (
<select>for booleans,type="number"/type="date",<textarea>for JSON), outgoing FK link that live-updates as you edit, and a "Referenced by" section listing incoming relationships as filtered table views. - Edit — double-click any cell for in-place editing, or open the row in a modal via the row's "edit" button. Both
paths submit
PATCH /:table?<pk>=eq.<id>and refresh the visible rows on success. - Insert — kind-aware form on
/t/{table}/new. Empty inputs are omitted from the POST so column defaults / NULL apply. - Delete — confirm dialog →
DELETE /:table?<pk>=eq.<id>. - Schema viewer — at
/t/{table}/schema: columns (name / type / nullable / default / generated-or-identity / comment), primary key, outgoing FKs, incoming FKs (Referenced by), and indexes (name, columns, UNIQUE, method, partial-index predicate). FK / Referenced-by entries link to the other table's schema page. - Schema diagram — at
/schema: a force-directed ER diagram of every table and its foreign keys; drag to pan, scroll to zoom, click a table to open its schema page.
- Light and dark themes — follow your OS preference by default, with a toggle that is remembered per browser.
- Responsive down to tablet widths (>= 768px).
- Read-only gating — when
read_only: truethe UI hides every write affordance (+ New, edit / delete buttons, inline-edit, modal, Save / Delete on row detail) and/t/{table}/newreturns 404. - Type-aware forms — boolean / integer / number / date / JSON / text inputs and the JS value parser dispatch on the
same Go-side
inputKindclassifier so client and server agree on the column shape.
The UI calls the same HTTP API you would. Cross-origin calls between the two listeners are handled automatically —
adms adds the UI's origin to the API's allowed origins, so you do not need to list it in cors_origins. When
auth_token_env names a populated env var, the UI carries that token on every request.
When read_only: true, the UI hides edit / insert / delete affordances. The UI does not introduce its own login
flow — keep it behind your network or gateway.
adms is designed to sit behind your authn layer (reverse proxy, API gateway, etc.), but it ships several built-in
safety nets so an accidental misconfiguration is not catastrophic.
Table and column names from query parameters are checked against the introspected schema before they are interpolated
into SQL. Unknown identifiers return 400 Bad Request, never reach the database, and never appear in error messages
echoed back to the client unsanitized.
read_only: trueReturns 403 Forbidden for POST, PATCH, and DELETE. The admin UI hides write affordances in this mode. Useful for
staging dashboards, demos, or anywhere writes must be impossible by construction.
Restrict which schemas (or tables) are exposed:
allowed_schemas: [public, reporting]
allowed_tables: [users, posts, comments]Anything outside the allowlist is invisible — at GET /, at the per-table endpoints, and in the UI sidebar.
auth_token_env: ADMS_TOKENWhen set, adms reads the bearer token from the named environment variable and requires every request to include
Authorization: Bearer <token>. The admin UI carries the token automatically (the resolved value is exposed via a
meta tag that an inline fetch wrapper picks up and attaches to every API-origin request). This is intentionally
simple — for OIDC / JWT, terminate auth at your gateway. The token value itself never appears in the config file, so
it does not leak into version control.
cors_origins:
- "https://admin.example.com"
- "https://staff.example.com"Defaults to no CORS headers, so the API is only reachable from same-origin contexts unless you opt in. When
ui.enabled: true, the bundled admin UI's origin is automatically added to the allowed origins — you do not need to
list it here.
PATCH and DELETE without a where clause return 400 — there is no "update every row" path, in the API or the UI.
adms [config-file]
If the argument is omitted, adms looks for adms.yaml, adms.yml, then adms.toml
in the current directory. Pass a path to use a specific config file.
Flags:
--version, -v Print version
--help, -h Show help
The config file is the single source of configuration — there are no per-setting CLI flags or ADMS_* reserved
environment variables. Strings in the config file are expanded via ${VAR} / $VAR from the environment so secrets
(DSN, bearer token, etc.) stay out of source control; the env var names you reference (ADMS_DSN, DATABASE_URL,
anything you like) are entirely your choice. Unset variables expand to "", and literal $ cannot be escaped, so
put values containing $ in an environment variable.
A minimal config:
driver: postgres
dsn: "${ADMS_DSN}"The full set of fields, with defaults and meaning:
| Field | Default | Description |
|---|---|---|
driver |
(required) | postgres or mysql |
dsn |
(required) | Database connection string (prefer ${VAR} expansion) |
listen |
:7777 |
API listen address |
read_only |
false |
Reject all write methods with 403 |
allowed_schemas |
(driver default) | Schemas to introspect |
allowed_tables |
(all) | Table allowlist (empty means every introspected table) |
timeout |
30s |
Startup operation timeout (DSN parsing, introspect, etc.) |
cors_origins |
(none) | Allowed origins for CORS |
auth_token_env |
(none) | Name of the env var holding a bearer token to require |
log_level |
info |
debug / info / warn / error |
ui.enabled |
false |
Mount the bundled admin UI |
ui.listen |
:7778 |
Listen address for the admin UI |
TOML works the same way:
driver = "postgres"
dsn = "${ADMS_DSN}"
listen = ":7777"
read_only = false
allowed_schemas = ["public"]
[ui]
enabled = false
listen = ":7778"Working examples live in examples/adms.yaml and examples/adms.toml.
Use PostgREST if you are PostgreSQL-only and want a battle-tested project with a large community — it is genuinely
excellent, and adms borrows heavily from its URL conventions.
Reach for adms when:
- you are on MySQL, or operating a fleet with both PostgreSQL and MySQL, and want one server to manage,
- you want a UI shipped in the same binary as the API, not a separate frontend project,
- you prefer a single self-contained Go binary with no Haskell runtime to deploy,
- you want a tighter scope focused on admin dashboards — opinionated defaults, identifier allowlists, mandatory filters on writes — rather than a general-purpose data API.
The URL conventions, the embedding syntax, and the Prefer-header semantics in this project are taken almost verbatim
from PostgREST. The admin UI is styled with Tailwind CSS,
generated by the standalone tailwindcss CLI and bundled into the binary at build time. Standing on giants'
shoulders — thank you.
If adms saves you time, consider supporting ongoing development
via GitHub Sponsors. Sponsorships pay for the maintenance time that keeps
Postgres / MySQL parity, security fixes, and new features moving.
