An agent is an automated moderator: a program, not a person, that staff let act on the admin members roster. It has its own site account and reaches the API with a grant: a bearer token an administrator mints for it, scoped to moderation and nothing else. It can do two things, each under its own scope:
- Vote
spam/not spamon a member who only looks like spam. Its votes are labeled as an agent's in the data and on the roster, they stage (see person-lifecycle.md → Scheduled spam purge), and any staff vote overrules them. - Purge a new member who has posted spam, at once, naming the record that is the spam and why (moderation.md → Agent purges). The site checks the evidence and a set of preconditions; it does not judge the content.
Those are the operator's two cases: "When someone actually posts spam, purge immediately. For signups that are obviously spam but haven't posted anything spammy, log a spam vote to put them in the 3-day purge queue pending doing anything that makes them not look like spam."
The first agent is Code for Philly's ops bot. The site does not care what runs behind a grant; it cares that the grant is narrow, attributable and revocable.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/api/admin/agents |
staff | Every agent account, its grants, and its recent purges. |
POST |
/api/admin/agents |
administrator | Create an agent account and its first grant. |
POST |
/api/admin/agents/:slug/grants |
administrator | Mint a grant; the token is returned once. |
POST |
/api/admin/agents/:slug/grants/:id/revoke |
administrator | Revoke a grant at once, and optionally withdraw the agent's staged votes. |
POST |
/api/admin/agents/:slug/withdraw-votes |
staff | Withdraw every vote the agent has staged, in one commit. |
Anyone else gets 404 not_found, like the rest of /api/admin. The
moderation endpoints an agent calls with its token are in
moderation.md.
An agent account is a people record created by an administrator here, never
by GitHub sign-in:
accountLevel = user, nogithubUserId, no legacy password, and no private profile (a private profile holds an email, and an agent has none). It cannot sign in to the site or to Slack: there is no identity for OAuth, a password or SAML to bind to. The only way in is a grant.- It is public like any member (
/members/<slug>), and itsbiosays what it is and who runs it. It is an agent because theagent-grantssheet holds a grant for it, active or not; nothing else marks it. A revoke or an expiry only ends a grant, never deletes its record (only purging the agent account itself does), and an agent is created with its first grant, so an agent whose every grant has been revoked or has expired is still an agent account, under every rule below. - It can never be raised to staff:
POST /api/people/:slug/account-levelrefuses (409 conflict,error.code = "agent_account") for a person with any grant on record. Agents are never staff, so an agent's vote and a human's vote can never come from the same account. - Moderation treats it as off limits: nobody votes on it
(
422,slug: agent), no agent purges it, and the scheduled spam purge never takes it. To stop an agent, revoke its grants. - Its slug is fixed:
PATCH /api/people/:slugrefuses to rename it (409 conflict,error.code = "agent_account"). Its votes are recorded asagent-<slug>and its purges asby = "agent-<slug>"; a rename would not follow those, and would reset the daily allowances counted from them. - No GitHub sign-in can take it over: account matching never offers it as a
candidate, a post-sign-up search does not find it, and staff approval of a
claim request naming it is refused (
409 conflict,error.code = "agent_account") (account-migration.md).
A grant is one bearer token for one agent account, with a fixed list of scopes and a hard expiry.
| Scope | Allows |
|---|---|
moderation:read |
GET /api/admin/members and GET /api/admin/members/:slug, with the redactions in moderation.md → Agent callers. |
moderation:vote |
POST /api/admin/members/:slug/vote and DELETE /api/admin/members/:slug/vote, within the agent vote rules. |
moderation:purge |
POST /api/admin/members/:slug/purge, within the agent purge rules. |
There are no other scopes. A grant does not make its holder user, self or
staff anywhere else (authorization.md → Agent grants).
moderation:purge is its own scope, not implied by moderation:vote, so an
administrator can let an agent stage votes without letting it delete anything.
A grant with moderation:purge must also carry moderation:read (422
otherwise): an agent that cannot read the roster cannot check what it is about
to delete. Scopes are fixed for a grant's life; adding purge to an agent is a
new grant and a revoke of the old one, so the change is its own public
agent.grant commit.
- Token:
cfpa_followed by 32 random bytes, base64url. Shown once, in the mint response. The site keeps only its SHA-256 hash, in the private store (private-storage.md); the publicagent-grantsrecord says that the grant exists, for whom, with which scopes, by whom and until when — never the token or its hash. - Presented as
Authorization: Bearer cfpa_…on each request. No cookie, so no CSRF surface. Never logged; never written to a commit. - Expiry:
expiresInDays1–180, default 90. An expired grant is refused like a revoked one. Renewal is a new grant and a revoke of the old one. - Revocation is immediate: the in-memory grant table is updated in the same
request. An agent's staged votes count toward a purge only while it holds
an active grant
(person-lifecycle.md):
once its last active grant is revoked (or expires) they stop counting, and
they stay on the record. If a new grant is minted for the agent later, they
count again, from the moment of the mint, unless they were withdrawn. So a
revoke for a compromised token passes
withdrawVotes: true, which withdraws them in the same commit; a routine rotation (mint the new grant, then revoke the old one) does not, and the votes count throughout. Revoking does not undo purges already made; those are restored one by one (person-lifecycle.md → Restoring a purged member). - Last used: the API records
lastUsedAton the private record, at most once an hour per grant.
{
"success": true,
"data": [
{
"person": { "slug": "example-bot", "fullName": "Example Bot", "deletedAt": null },
"grants": [
{ "id": "…", "label": "ops bot, host vm-1", "scopes": ["moderation:read", "moderation:vote"],
"createdAt": "…", "createdBy": { "slug": "jane", "fullName": "Jane Doe" },
"expiresAt": "…", "revokedAt": null, "lastUsedAt": "…", "active": true }
],
"stagedVotes": { "spam": 3, "legit": 1 },
"purges": { "last24h": 1, "last30d": 4 }
}
]
}stagedVotes counts the agent's votes on people no human has voted on.
purges counts the agent's spam-purges records; the records themselves, with
reason and evidence, are GET /api/admin/spam-purges?by=agent-<slug>
(moderation.md).
Staff, not only administrators, may list agents: the panel that shows what an agent staged and purged is a staff review surface, and nothing here is secret (grants are public records; tokens are never returned after mint).
{
"slug": "example-bot",
"fullName": "Example Bot",
"bio": "An AI ops bot run by … Its spam votes on /admin/members are staged and staff overrule them; it purges only accounts that posted spam, and says why.",
"grant": { "label": "ops bot, host vm-1", "scopes": ["moderation:read", "moderation:vote"], "expiresInDays": 90 }
}bio is required (an agent says what it is), and so is grant: an account
is an agent because it holds a grant, so an account created without one would
be an ordinary member with no sign-in. Creates the person and the grant (its
token hash in the private store) in one transaction; the slug follows
slug-handles.md, including the rule that a
slug with moderation history is not reused
(slug-handles.md).
Response 201: { person, grant, token } — token only in this response.
{ "label", "scopes", "expiresInDays" } → 201 { grant, token }. 404 when
slug is not an agent account (a member cannot be turned into an agent: create
the agent instead).
{ "withdrawVotes": true }The body is optional. Sets revokedAt = now(). 200 { grant }. Revoking a
revoked grant without withdrawVotes is a no-op (no commit).
withdrawVotes: true is for a token that may be compromised: in the same
transaction and commit as the revoke, it withdraws every vote the agent has
staged, exactly as withdraw-votes
does with no since (the agent's votes on people no human has voted on;
the members its votes hid are reactivated). The response adds { withdrawn, reactivated }. On a grant already revoked it still withdraws (the compromise
may be found after the revoke). The commit carries Withdraw-Votes: true.
The undo for an agent that has gone wrong. Staff, not only administrators: anyone who can overrule a vote one at a time can overrule them all.
{ "since": "2026-11-01T00:00:00Z" }since is optional. Deletes every agent-<slug> evaluation (newer than
since) on a person with no human vote, and reactivates each such person
whose deactivation that agent's vote caused (the reversal
rule). Votes on
people a human has since voted on are left as the record, and so are votes on
people who no longer exist (purged: their votes are the record of why). One
commit.
Response 200: { "withdrawn": 7, "reactivated": 5 }. Withdrawing does not
revoke the grant; when the token itself is in doubt, revoke with
withdrawVotes: true, which does both in one commit. Withdrawing does
not touch the agent's purges: there is no bulk undo for a delete. Each is
restored on its own
(person-lifecycle.md → Restoring a purged member),
and the purge log names every one (moderation.md).
Every write here is a commit on the data repo through the write mutex, by the
calling administrator or staff member, with Action: agent.create,
agent.grant, agent.revoke or agent.withdraw-votes, Subject-Type: person,
Subject-Slug: <agent slug>, and Grant-Id: <id> where one grant is
concerned. Agent grants are a change of access, so they are in the public audit
log the same way an account-level change is. (An agent's own votes and purges
are committed as the agent: moderation.md.)
422 validation_failed— unknown scope,moderation:purgewithoutmoderation:read,expiresInDaysout of range, missingbioorgrant, bad or taken slug, or a slug with moderation history (slug_retired)404 not_found— not an administrator (staff forGETandwithdraw-votes), or no such agent or grant