The proposal API lets an external client suggest custom-command changes without applying them immediately. Each proposal is stored in SQLite and posted to a configured Discord review channel. A staff member must approve or reject it, unless approval is disabled — see Disabling approval.
Approval writes through the configured command repository. This works with both the default local SQLite provider and the optional GitHub provider.
Enable proposals in config.json:
{
"proposals": {
"enabled": true,
"review_channel_id": "123456789012345678",
"max_pending": 5,
"ttl_hours": 72,
"rate_limit_per_minute": 100,
"db_path": "./data/proposals.db"
}
}Set COMMAND_API_KEY in .env. The proposal and command-read endpoints share the same key and rate-limit budget.
The key must be at least 32 characters; startup refuses a weaker one. Generate it with:
openssl rand -hex 32The proposal store records reviewer identities and review message IDs from Discord; see Data storage for the encryption-at-rest obligations that come with it.
The built-in HTTP server speaks plain HTTP. The default http.host of 127.0.0.1 keeps it unreachable from the network, which is the right choice when the proposer client runs on the same machine.
If the API must be reachable from another host, do not bind it to a public interface directly — put a TLS-terminating reverse proxy in front of it and keep the bot on localhost:
server {
listen 443 ssl;
server_name commands.example.com;
ssl_certificate /etc/letsencrypt/live/commands.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/commands.example.com/privkey.pem;
location /api/ {
proxy_pass http://127.0.0.1:9010;
}
}Caddy terminates TLS with even less configuration: commands.example.com { reverse_proxy 127.0.0.1:9010 }.
Without TLS the X-API-Key header crosses the network in plaintext and can be replayed by anyone who observes it.
Behind a reverse proxy every connection arrives from the proxy's address, so set http.trusted_proxies in config.json to the proxy's address (usually ["127.0.0.1", "::1"]) — otherwise the failed-auth throttle described below collapses all clients into one shared bucket that any abuser can exhaust. With a trusted proxy configured, the client identity comes from X-Forwarded-For (rightmost untrusted entry). X-Forwarded-For from any source NOT in trusted_proxies is ignored, so a directly reachable client can never spoof its identity.
Send the key in every request:
X-API-Key: your-shared-keyKeys are compared in constant time. Missing or incorrect keys return HTTP 401. After 10 failed attempts within a minute from the same source IP, every request from that source returns HTTP 429 until a minute passes without further failures.
GET /api/v1/commands
GET /api/v1/commands?detail=full
GET /api/v1/commands/{name}
GET /api/v1/commands/search?q=welcome
Summary responses contain command names, descriptions, and page presence. Full, single-command, and search responses include exact raw repository bodies. Clients should read the current raw body immediately before constructing guarded patch edits.
Search accepts:
qorquery: required, at most 256 characters and 16 termslimit: 1–50, default 10min_score: 0–100, default 45
Read errors use { "error": "..." }.
POST /api/v1/commands/proposals
Content-Type: application/json
X-API-Key: your-shared-key
The body is limited to 256 KiB:
{
"operation": "create",
"command_name": "welcome",
"command": {
"format": 2,
"name": "welcome",
"description": "Welcome information for new members",
"blocks": [
{ "type": "heading", "text": "Welcome" },
{ "type": "text", "text": "Start here to learn about the community." }
]
},
"rationale": "Add a concise starting point for new members",
"proposer": "assistant"
}Supported operations:
create: requires a full format-2commandbody.delete: removes an existing command after approval.patch: requires a non-emptyeditsarray.edit: accepted as an alias forpatch; full-body edits are rejected.
The response shape is always:
{
"status": "staged",
"message": "proposal staged for staff review",
"proposal_id": "0123456789abcdef0123456789abcdef"
}| Status | HTTP | Meaning |
|---|---|---|
staged |
201 | Stored and posted for review |
applied |
200 | Written immediately because approval is disabled |
duplicate |
200 | A matching pending proposal already exists |
too_many_pending |
429 | The review queue reached its configured limit |
invalid |
400 | Request or resulting command is invalid |
conflict |
409 | The proposal does not match current repository state |
unavailable |
503 | Repository, proposal service, or review channel is unavailable |
error |
500 | Internal failure |
Edits are applied in order to a deep copy of the current raw command body. The result is validated before staging and re-applied to the latest authoritative body during approval.
The old text must occur exactly once in the selected target:
{
"operation": "patch",
"command_name": "welcome",
"edits": [
{
"type": "replace_text",
"old": "Start here",
"new": "Begin here"
}
]
}A target may select a command, page, or block:
{
"type": "replace_text",
"target": { "kind": "page", "page": "getting_started" },
"property": "description",
"old": "Old description",
"new": "Updated description"
}Pages can be referenced by zero-based index, normalized name, or title. Blocks can be referenced by zero-based index or by the exact name of a field block.
old is an optimistic-concurrency guard. null also matches an absent optional property:
{
"type": "set_property",
"target": { "kind": "command" },
"property": "thumbnail_url",
"old": null,
"new": "https://cdn.example.com/welcome.png"
}{
"type": "insert_item",
"item_type": "block",
"target": { "kind": "page", "page": "getting_started" },
"position": "end",
"item": { "type": "small", "text": "Updated regularly" }
}item_type is page or block. Positions are zero-based indices or "end".
Removal requires the complete current item as an old guard:
{
"type": "remove_item",
"target": { "kind": "block", "block": 0 },
"old": { "type": "heading", "text": "Welcome" }
}{
"type": "move_item",
"target": { "kind": "page", "page": "faq" },
"old": {
"name": "faq",
"title": "FAQ",
"blocks": [{ "type": "text", "text": "Common questions." }]
},
"position": 0
}Moves require the complete current item as an old identity guard, preventing an index from silently retargeting another page or block before approval.
Approval:
- Atomically claims the pending proposal in SQLite.
- Re-reads the current authoritative command.
- Re-applies semantic guards.
- Uses the repository revision for a conditional write.
- Retries once only for a revision race.
- Updates the live catalog and Discord registration.
A timed-out remote write is treated as indeterminate and is not retried blindly. Staff should reload the repository before submitting another proposal.
Set require_approval to false to apply valid proposals on submission instead of staging them:
{
"proposals": {
"enabled": true,
"require_approval": false
}
}Submissions then return applied with HTTP 200 instead of staged with 201. Every step of the approval sequence above still runs — the claim, the re-read, the semantic guards, the conditional write, and the registration update — so a proposal that would have been rejected as invalid or conflicting is still rejected. Only the human gate is removed.
review_channel_id becomes optional. If it is set, each applied proposal is still posted to that channel as a record, without approve and reject buttons. Proposals remain in the SQLite store, resolved by Automatic approval, and repository commits are still attributed to the proposer.
Anyone holding COMMAND_API_KEY can create, edit, and delete commands unattended in this mode. Use it only where the key is as trusted as a staff member. The default is true, and a host that omits the setting requires approval.