Reference
HTTP API
Every route the AgentsVille API server serves, grouped by feature area. Paths are
given in full, including the prefix each route group is registered under in
apps/server/src/index.ts.
Conventions
Requests and responses are JSON unless a route says otherwise. Four routes return an event stream and one returns HTML; they are marked where they appear.
An error response is an object with an error string and a code string:
A 400 raised by body or query validation also carries an issues array — the
raw validation issues — on the routes that pass them through. Two responses
depart from the { error, code } shape and are called out where they appear:
/health answers with status rather than error, and the sign-up gate adds a
message alongside error because the better-auth client reads message.
Identifiers are UUIDs generated by the application, not by the database.
Sessions travel as cookies. The browser app and the API are served from
separate origins, so requests are cross-origin: the API allows Content-Type,
Authorization, X-Requested-With and x-agentsville-invite, sends
Access-Control-Allow-Credentials, and allows the origin named by
CORS_ORIGIN.
The request body limit is 1,000,000 bytes.
Rate limits
Two limits apply, both keyed on the client address:
Over the limit, the rate-limit plugin answers 429 with its own body rather than
the { error, code } shape used elsewhere.
The sign-up route does not fail open. Its preHandler pings Valkey first and
answers 503 LIMITER_UNAVAILABLE if the ping fails, because a shared secret
with no attempt limit is not a gate.
Errors any authenticated route can return
Every route below that requires a session can return these. They are not repeated in the per-group tables.
Root and infrastructure
/health answers 200 { "status": "ok", "service": "agentsville" } when the
query succeeds, and 503
{ "status": "unavailable", "code": "DATABASE_UNREACHABLE" } when it does not.
This response has no error key.
The service field is how a web UI or the desktop app tells a Village from
some other server that happens to answer.
/api/models returns an array of { provider, id, label }. An entry is present
only if ANTHROPIC_API_KEY or OPENAI_API_KEY is set for that Provider, so the
NPC form cannot offer a Model that could never run.
/api/stream-check sets Content-Type: text/event-stream,
Cache-Control: no-cache, no-transform, Connection: keep-alive and
X-Accel-Buffering: no. pnpm sse:check reads it — see
Scripts.
Authentication
better-auth handles registration, sign-in, sign-out and session lookup. AgentsVille wraps it with an Invite Code gate and a tighter rate limit.
A preHandler runs ahead of both for any URL starting /api/auth/sign-up. It
checks the Invite Code, presented in the x-agentsville-invite request header.
Only a POST to the rate-limited sign-up route is admitted, and a path is
classified after WHATWG URL normalisation, so an encoded or dotted variant of
/api/auth/sign-up/email is not a way round it. The code is looked up by its
keyed hash and, if it has a use left, one is taken. A repeated header is read
as its first value rather than refused.
INVITE_EXPIRED and INVITE_USED_UP are answered only to someone holding a
real code; anything else gets INVITE_REQUIRED. REGISTRATION_UNAVAILABLE is
the invite check failing to reach the database. The 403s and the 503s carry a
message field with the same text as error, so the better-auth client can
read it. Every other status on these paths comes
from better-auth itself.
Invite codes
Registered under /api/invite-codes. Operator only; anyone else gets 403
NOT_OPERATOR.
NPCs
Registered under /api/npcs. Every route is scoped to the NPCs the calling
Player owns.
The create body takes name, and optionally systemPrompt (stored as ""
when absent), avatar (npc-01 … npc-50; picked at random when absent),
jobTitle, description, thinking (off, low, medium, high; default off),
provider (a Provider id), model, and levels — any of beginner,
advanced, expert, ultra, stored once each in that order, with an empty
list stored as null; and usageLimitTokens (1 to 2,000,000,000) and
usageLimitWindow (day, week, month), set together or both null. Create
also takes tools, grants in the shape
PUT /:npcId/tools accepts, validated the same way. The update body is the same
fields but tools, all optional.
POST /api/npcs/draft takes { name, jobTitle?, description? } and returns
{ systemPrompt, levels, tools }, a draft written by the Village default model or
the cheapest mapped Level. It saves nothing. tools lists only builtins and the
Player's own MCP servers and Village Skills, each with a name. It answers 400
NO_MODEL_CONFIGURED with no model to draft on, and 502 DRAFT_FAILED when the
model cannot produce one — often a small local model without structured output.
provider and model move together. Setting one without the other is a 400
with the message Choose a provider and a model together, or inherit both on
the model path. Both null means the NPC inherits the Village default — see
Choosing models.
usageLimitTokens and usageLimitWindow move together in the same way: a 400
INVALID_BODY names usageLimitWindow when only one is set.
NPC tools
Registered under /api/npcs, because a grant only means something in the
context of the NPC it is for.
The GET returns { grants, inherited, available }. inherited lists what the
NPC receives from its Groups and from all-NPCs grants, as { kind, ref, from }.
available is
{ builtins, mcpServers, skills }. builtins is the sorted list
current_time, fetch_url, memory, sandbox — the last two are grants that
turn on a family of Tools rather than single Tools. mcpServers is the
Player's MCP Servers as { id, name, transport, enabled }. skills is the
Skills scoped to this NPC or to the whole Village, as
{ id, name, description, npcId }.
The PUT body is { grants: [{ kind, ref, enabled }] }, where kind is
builtin, mcp or skill and enabled defaults to true. A row with
enabled: false switches off, for this NPC only, something a Group or all-NPCs
grant gives it. The set is replaced in one transaction. It returns { grants }.
Groups
Registered under /api. Every route is scoped to the calling Player's Groups
and NPCs.
A Group or all-NPCs grant may name builtins, the Player's MCP Servers and Village
Skills; a Skill scoped to one NPC is refused with UNKNOWN_TOOL. NPC_NOT_FOUND
(400) means npcIds named an NPC the Player does not own, GROUP_EXISTS (409) a
duplicate name, GROUP_NOT_FOUND (404) a Group that is not the Player's.
Host Mounts
Registered under /api/host-mounts. Operator only; anyone else gets 403
NOT_OPERATOR.
A path outside the roots, with .., or with characters outside
A-Z a-z 0-9 . _ / - is refused with HOST_MOUNT_REFUSED; with no roots set,
every mount is refused with HOST_MOUNTS_DISABLED. The broker repeats the same
check on every ensure.
Sandboxes
Registered under /api/npcs. Everything here reaches the container through the
sandbox broker; the API process never touches Docker.
The GET returns { enabled, granted, egress, state }, plus startedAt when
the broker reports one. enabled is false when this instance has no sandbox
service configured, and state is then "absent" — a Village without sandboxes
is a normal configuration, not an error.
The PUT body is { granted, egress }, egress defaulting to false.
granted: false deletes the row and destroys the machine, so revoking a
Sandbox does not leave a filesystem behind. egress: true is refused for
anyone who is not the Operator.
POST .../exec takes { command, timeoutSeconds }, where timeoutSeconds is
a positive integer up to 600. It answers with the same SSE headers as the other
streams and emits one data: frame per chunk from the broker. A failure mid-run
is reported inside the stream as a stderr chunk followed by
{ "kind": "exit", "code": -1, "timedOut": false }, not as a status code.
Any sandbox failure surfaces as a 400 carrying the underlying code:
MCP servers
Registered under /api/mcp-servers.
The body takes name, transport (stdio, http or sse), and then
command and args for stdio or url for http and sse, plus optional
headers and enabled. headers is sent in plain text, encrypted at rest and
never returned: a read answers hasHeaders: true or false instead. On a
PATCH, omitting headers leaves the stored ones alone and an explicit null
clears them.
A URL must parse, must be http: or https:, and must not resolve to a
private address. Failing any of these is a 400 with the message
That URL is not reachable from the server on the url path. A stdio server
runs a command as the API process, so only the Operator may register one.
POST .../test answers 200 either way: { ok: true, tools } on success, or
{ ok: false, error, tools: [] } when the connection fails, because what the
Player needs is the connection error rendered in the form they are filling in.
Connectors
Registered under /api/connectors. A Connector is an MCP Server plus the
credentials to reach a third-party service.
The catalogue entry is { kind, label, summary, credential, tokenHint, available }. credential is token or oauth. An OAuth service with no
client registered is listed as available: false rather than hidden, so a
Player can ask their Operator for it.
The list returns { id, kind, label, status, mcpServerId, expiresAt, createdAt }. accessToken and refreshToken live on the same table and are
never selected by this route.
POST /api/connectors takes { kind, token }. For a token service the reply is
201 { id, status: "connected" }. For an OAuth service it is 201
{ id, status: "pending", authorizeUrl }, and the pending flow — its PKCE
verifier, the Connector id and the owner — is held in Valkey for ten minutes.
The callback is unauthenticated by necessity: it is a top-level browser
navigation from somebody else's site, so the state parameter is what ties it
to the flow that started it. The flow is read and deleted in one step, which is
what stops a replayed callback finishing twice. On success it answers HTML
saying the service is connected. On any failure the half-made Connector row is
deleted.
Conversations
Registered under /api/conversations.
POST /api/conversations takes exactly one of { npcId }, { npcIds } or
{ groupId }, and optionally leadNpcId, and always creates a new
Conversation, so a Player may hold many with the same NPC. Several NPCs, or a
Group's members, make it a Party from the start, in that order; the first is the
NPC it is filed under. A Lead who is not in it is refused with
LEAD_NOT_IN_PARTY, an empty Group with GROUP_EMPTY.
GET /api/conversations?party=true lists only Conversations with more than one
NPC, each with participants (npcId, name, avatar). PATCH takes
title, leadNpcId (null clears the Lead), or both.
POST .../messages takes { content, level? }. level pins the reply to one
Level instead of routing it (see Choosing models).
The Player's Message is written before
the stream opens; the Conversation's title is set from the first 80 characters
of the first Message and its updated_at is bumped on every send. History is
bounded before it is sent, and what falls off the front is written into the
Conversation's summary first.
POST .../regenerate takes an optional { level }, deletes the trailing
Message if it is from an NPC, then re-answers the last Player Message. Re-posting the text instead would duplicate
the Player's Message, because it is persisted before the stream opens.
Both streaming routes settle the Provider and Model before opening the body, because once the body is open the status is fixed at 200.
The message stream
Both streaming routes answer 200 with Content-Type: text/event-stream,
Cache-Control: no-cache, no-transform, Connection: keep-alive and
X-Accel-Buffering: no. Each frame is one data: line holding JSON,
discriminated on type rather than on the SSE event name:
Errors that happen after the body is open arrive as an error frame:
An aborted request tears down the upstream call and still writes what was generated, so a cancelled reply is not lost.
Tasks
Registered under /api/conversations: a Party's task board, the same rows the
members change with task_add and task_update.
An assignee must be in the Party (NOT_IN_PARTY).
Ongoing Tasks across chats
Participants
Registered under /api/conversations. One NPC is the ordinary case; adding a
second makes it a Party.
The GET returns { participants, available }. Each participant is
{ npcId, name, jobTitle, turnOrder }, ordered by turn_order then added_at.
available is every NPC the Player owns, as { id, name, jobTitle }.
The POST body is { npcId }. The new participant's turnOrder is the number
of participants already present.
Memory
Registered under /api/memories. Every Memory is listable, editable and
deletable by the Player who owns it.
Rows come back as { id, npcId, scope, content, createdAt, lastUsedAt }. The
stored embedding is never returned.
?npcId= returns that NPC's Memories and the Village-scoped ones, because
those are genuinely in scope for it. ?search= matches content as a literal
substring: % and _ in the search text mean themselves.
The POST body is { npcId, content, scope }, where content is 1 to 2000
characters and scope is npc (default) or village. The PATCH body is
{ content }, same limits; editing clears the stored embedding, so the Memory
is found by keyword until something re-embeds it.
Skills
Registered under /api/skills.
The body takes name, description, instructions and npcId. A null
npcId means the Skill is available to every NPC in the Village. Filtering the
list by ?npcId= returns that NPC's own Skills and the Village's, because that
is what is actually in scope for it.
Settings
Registered under /api/settings. These are the Village's defaults, shared by
every NPC in the instance.
Both answer with
{ defaultProvider, defaultModel, levels, embeddingProvider, embeddingModel, font, usageLimit, providers, canEdit }.
levels maps beginner, advanced, expert and ultra to
{ provider, model }, with unmapped Levels absent. embeddingProvider and
embeddingModel are what Memory is embedded with; both null means OpenAI's
text-embedding-3-small when OPENAI_API_KEY is set, and keyword ranking
otherwise. providers lists
{ id, name, kind, configured, builtIn } — never an address or a key. Every
Player may read this — an NPC form has to show what "inherit" resolves to — but
only the Operator may change it. font is the Village font (jersey-25,
geist-pixel, handjet, fira-sans, michroma, cinzel or
commissioner), or null for each Skin's own fonts. usageLimit is the Village
Usage limit, { tokens, window } with window one of day, week, month,
or null.
The PATCH body takes any of defaultProvider, defaultModel, levels,
embeddingProvider, embeddingModel, font and usageLimit; font and
usageLimit may each be null. Each Provider and Model pair must be
set together or both null; levels replaces the whole map. A Provider id with
no Provider behind it is refused, and so is an Anthropic Provider for
embeddings.
Providers
Registered under /api/providers. Where the Village's models run: the built-in
anthropic and openai, plus any the Operator adds. Everything but the model
list is Operator only.
The id is derived from the name (LM Studio becomes lm-studio). A base URL
must be http or https with no credentials in it, and is deliberately not
checked against the private-network policy — local models live on localhost
and the LAN, and only the Operator can set one
(ADR 0022).
Preferences
Registered under /api/preferences. The signed-in Player's own settings — their
Skin, their Villager and their font.
Both answer { skin, villager, font, villageFont }. font is the Player's own
font, or null for the Village font, which is villageFont. villager is a whole number from 0 to 49, the
person the Player walks the Village as, or null when they have never chosen
(the web client then picks one from their id). skin is 1 to 64 characters: "classic", "farm", "office",
"forest", "dungeon", "caves" or the id of an uploaded Skin. GET answers "farm" when the Player has
never chosen, or when the stored id names a deleted Skin — farm is the default, not a separate state — and "classic" instead when that Player hid Farm. A Village built
before ADR 0033 answers "village" instead, which the web client reads as
Farm. See Skins.
The PATCH body takes any of { skin }, { villager } and { font };
villager may be null to clear the choice, and font may be null to go
back to the Village font. At least one is required.
A web UI connected to a Village built before this route existed gets a 404 for both; the client reads that as "no preference to load" rather than as an error. See ADR 0031.
Skins
Uploaded Skins (Skin packs). Every route but the file read needs a session.
name is 1 to 60 characters. base is one of the five built-in pixel Skins.
skin is the pack's skin.json (theme, pathEdge, tokens), every part
optional. Its colours are #rrggbb in either case and are stored lowercase; a
colour that is not, or a token name a Skin does not have, is an INVALID_PACK
problem. files maps an art path (characters/07.png) to the file's base64.
A path key is at most 64 characters, a file at most 64 KB decoded (checked as
the length of its base64), and a pack at most 140 files; tokens has at most
as many entries as a Skin has tokens (21). A longer key, a larger file, too
many files or tokens, or any other fault in the body's schema is a 400
INVALID_BODY carrying issues, not INVALID_PACK. The body limit on POST /api/skins is 8 MB; every other API route stays at
1 MB. A reverse proxy must allow 8 MB on the API origin; the nginx profile does.
The server accepts only the paths and exact sizes in the art README, reading each size from the PNG header before decoding. A PNG must have one header chunk, must not be interlaced, and must inflate to exactly its size. Every file is decoded and re-encoded from pixels, so metadata is dropped. Colours merge with the base Skin's, and every text and surface pair must reach 4.5:1 (focus rings and control borders 3:1), counting frames the pack replaces.
Each Skin is answered as a view:
{ id, name, base, theme, pathEdge, tokens, createdBy, createdAt, files },
where files maps each art path the pack replaces to
/api/skin-files/<fileId>. GET /api/skins answers { skins, hidden } (the
views, oldest first, and this Player's hidden ids). POST /api/skins answers
201 with the new view. DELETE /api/skins/:id answers { id }. Both hidden
routes answer { hidden: [ids] }, the Player's whole list afterwards;
hiding a Skin twice changes nothing.
GET /api/skin-files/:id is public because an <img> or a CSS url() cannot
send a bearer token; the id is unguessable, and the file is cached as immutable.
Achievements and Rank
Registered under /api/progress.
A POST, not a GET, because it may write player_achievement rows — AgentsVille has
no state-changing GET. The body is {}; sending anything else is refused.
It answers { xp, rank, rankXp, nextRankXp, achievements, newlyUnlocked }.
achievements is the whole catalogue, each as
{ id, title, description, xp, unlockedAt }, unlockedAt null for one not
yet earned. newlyUnlocked lists the ids this call awarded — what the web app
toasts — and is empty on a call that awards nothing. rankXp and nextRankXp
are the XP the current and next Rank need. See
Achievements and Rank.
Stats
Returns:
Messages have no owner of their own, so the scoping is a join through the Conversation.
Usage
Registered under /api/usage. The Usage ledger: one row per model call.
GET /api/usage takes period (day: 30 daily buckets, week: 12 weekly
buckets from Mondays, month: 12 monthly), offsetMinutes (the browser's
offset east of UTC, −840 to 840, so buckets are cut at the Player's midnight)
and optionally npcId. It answers
{ total: { inputTokens, outputTokens }, series: [{ start, inputTokens, outputTokens }], npcs: [{ npcId, name, inputTokens, outputTokens }] },
every bucket present with zeros where nothing ran, NPCs largest first. A null
npcId collects drafts and Level ratings.
GET /api/usage/limits answers { village, npcs: [{ npcId, status }] },
where each status is { tokens, window, used, over } and village is null
when the Operator set none. A limit only ever warns
(ADR 0040).
Templates
Read-only and seeded by migration. A Template is content rather than data: an Operator who wants a different set edits the rows or the migration. See Templates.
The sandbox broker
The sandbox broker is an internal service, not part of this API. It publishes no ports, sits on the internal Compose network, has no route through the reverse proxy, and is reached only by the API server with a bearer token — so its endpoints are not documented here as a public surface. See The sandbox boundary.