The deployment itself¶
Most of this product is about agents. This page is about the thing they run inside.
One installation, with a name, a mark, a rule about who may join it, and a switch
that closes it. All of it lives in a single database row and is edited from
/admin/settings by whoever holds is_app_admin — no redeploy, no environment
variable, no rebuild.
Why that authority, and not a permission
A permission is scoped to an organization. This row is not in one.
It is the same authority that already administers users and tenants across the installation.
Identity¶
| Field | Where it shows |
|---|---|
| Name | The sidebar, the sign-in header, the browser tab, the OpenGraph card, and every email this deployment sends |
| Tagline | Beside the name in the tab title and on a shared link |
| Description | The page description and link previews |
| Logo | Wherever the name appears — the brand link, the sign-in header, the legal pages |
| Favicon | The browser tab |
| Footer text | Under the sign-in form |
| Terms URL, Privacy URL | Every link that otherwise offers the built-in /legal/* pages |
A null column means the built-in, not empty
An operator who clears a field is asking for the default back, not for a sign-in header with no name on it. The API answers overrides and each renderer resolves a null against its own built-in.
An operator who has never opened the page has no row at all. The console resolves
a null against APP_NAME and SITE in frontend/src/lib/; the backend resolves
it against settings.PROJECT_NAME for the mail it sends itself.
Two constants for one product name can drift, so
backend/tests/test_deployment_settings.py pins them equal. It reads the
frontend's constants.ts and compares it to Settings.PROJECT_NAME's class
default — the same bargain TestFrontendToolCatalog takes with the tool catalog.
The two images¶
Uploaded through POST /api/v1/admin/settings/{logo,favicon} and stored the way
every other image in this platform is: the bytes go to the configured file
storage and the key goes to a column. The key is never taken from a request
body — a caller who could name it could point this deployment's public logo at
whatever the storage backend holds — and the stored filename is minted from the
validated content type rather than from the upload's own name, because these files
are served from the origin the app's own pages run on and logo.html there is a
script.
JPEG, PNG, WebP and GIF, up to 2MB, which is the one definition of "an image this platform accepts".
SVG is deliberately absent
It is a document that may carry script, and these files are served from the origin the app's own pages run on. ICO buys nothing a PNG favicon does not.
The branding response carries a version, not a URL. The address is constant
(GET /api/v1/branding/{logo,favicon}) and the bytes are served immutable for a
year, so what a client needs from the row is whether an image exists and when it
last changed; the ?v= built from that is the only reason a replacement ever
appears. A URL would also be one every client had to rewrite, since in any real
deployment the API is not on the same origin as the pages.
Security headers¶
Every console page carries a Content-Security-Policy and the usual hardening
headers, defined in frontend/src/lib/csp.ts and
frontend/src/lib/security-headers.ts, both asserted by tests. The policy is
default-src 'self' with a connect-src naming exactly this origin,
PUBLIC_API_URL and PUBLIC_WS_URL, img-src allowing data: for the brand
glyphs and avatars, frame-src 'self' blob: for document previews, object-src
'none', base-uri 'self' and frame-ancestors 'none'.
The policy is stamped per request by the frontend's middleware, because the two
public URLs are read from the server's environment at runtime and a header set
at build could only name localhost. The other headers are constants and are
set by Next's configuration. Change the public URLs and the policy follows on
the next request; nothing is rebuilt.
Alongside it sit X-Content-Type-Options: nosniff, X-Frame-Options: DENY,
Referrer-Policy: strict-origin-when-cross-origin, and a Permissions-Policy
that denies camera and geolocation and allows the microphone only on this
origin, for the chat's speech-to-text.
A reverse proxy must not add its own copies of these
Nginx, Traefik or an ALB in front of the app passes these through unchanged
rather than setting its own. Two Content-Security-Policy headers on one
response are combined by the browser into their intersection, so a proxy that
adds a second — even a laxer one — only tightens the policy into something
that blocks a pane nobody meant to block; and a second X-Frame-Options lets
the browser pick either value. The bundled nginx/nginx.conf sets only
Strict-Transport-Security, which belongs to whatever terminates TLS; an
existing proxy configuration that adds the others should drop them.
Who may register¶
signup_mode, applied in app/services/signup_policy.py — the one place, and it
gates both paths that mint an account.
| Mode | Effect |
|---|---|
open |
Anybody may register. The default, and what every deployment before this feature was. |
invite_only |
Only an address some organization has actually invited. |
closed |
Nobody registers, by any route — an invitation does not override it. |
Across all three, a non-empty allowed_email_domains narrows who may register at
all. An invitation overrides that list — somebody holding members:invite
named the address on purpose, and a domain list is deployment policy for strangers
rather than a veto over a deliberate act. closed is overridden by nothing,
because "closed" that lets some registrations through is not closed.
closed means closed, and there is no administrator-creates-an-account path.
Deliberately: an account needs a password its owner chose, so adding somebody means
opening registration to them — which is what invite_only is for. A mode that
let an administrator mint accounts would be a third path minting one, and the two
that already exist are the whole reason signup_policy is a module rather than a
check inside register. So a deployment that has to admit one more person switches
to invite_only and invites them.
Three more things about this that are easy to get wrong, and were:
The first user is always admitted. A fresh installation has no accounts, so
its administrator does not exist yet; a closed deployment that also refuses the
person who would open it is one nobody can enter, with no console to fix it from.
register already promotes that first account to is_app_admin, and the policy
defers to the same fact.
invite_only exists because closing registration would otherwise break
invitations. InvitationService.accept requires an existing signed-in user, so
an invited person has to register first. The policy asks
invitation_repo.first_pending_admitting, which is cross-tenant by construction —
registration happens before an organization is chosen. What keeps that safe is
where the answer goes: the policy turns it into a boolean refusal, so a stranger
probing the sign-up form learns that somebody invited the address and never which
organization did.
How an invitation is recognised depends on whether the registration carries its token, and the two answers cover different shapes:
| Arrives with | Recognised by | Which shapes it admits |
|---|---|---|
| A token (resolved from the staged invitation, never on the sign-up body) | invitation_admission.admits |
Any live invitation that admits the address — including a link constraining no address, which is the shape nothing else can see |
| No token | invitation_repo.first_pending_admitting |
An email invitation for that address, or a link scoped to its domain |
The token is the only proof available for a shareable link with neither an address nor a domain on it. A query over the submitted address cannot recognise one, so honouring it without proof of possession would turn a single open link anywhere in the deployment into open registration for the whole internet. Holding the token is that proof.
A token that names nothing live falls through to the address question rather than refusing: a stale link in a bookmark should not turn a registration that would otherwise be allowed into an error about something the person cannot fix.
Registering with a token does not accept the invitation. It admits the account
and nothing else; joining the organization is still InvitationService.accept, which
the client calls once it has a session. A token in an unauthenticated sign-up body
that also granted membership would be a membership grant on a public route.
The console never carries the token across the sign-in round trip. An invitee with
no account opens /invitations/<token>; AuthGuard exchanges the token server-side
for an opaque handle it keeps in an httpOnly cookie the browser cannot read, then
sends them to /login?returnTo=/invitations/pending?flow=… — a landing with no
credential in it, so the token is not in the returnTo, in browser history, or in
session storage. If the exchange fails — the server unreachable, a rate limit — the
guard stays on the invitation link and offers to try again rather than leaving with
nothing staged, because the link is the only credential the invitee holds.
The flow is a random id the exchange mints per staging, and the cookie is named
for it. It is not a credential: without the cookie it names nothing. It is there
because one fixed cookie name is one slot — two invitation links opened side by side
while signed out overwrote each other, and both pending tabs then redeemed the second.
Each tab now redeems exactly the cookie its own flow names.
"Create an account" carries that credential-free landing on, and the register proxy forwards the named flow's staged handle as a header, so the sign-up admission still has the token it needs without the token ever being in a URL or the body. After sign-in the pending page redeems the handle and accepts — the same shape as the OAuth code exchange, an opaque single-use expiring stand-in for a credential so the credential never rides a URL. The cookie is cleared once the redeem has run; a 401, a 429 or a server failure leaves it, because the handle may still be unspent and a retry needs it.
A link with a max_uses bounds accounts, not only joins.
used_count counts acceptances, and acceptance needs a session — so a ceiling read
off it alone bounded nothing a registration did. One one-use link posted in a channel
admitted as many accounts as anybody cared to create, on the deployment that had just
closed sign-up.
So a use is reserved for the registering address first: reserved_emails on the
row, and used_count + reserved_emails is what "used up" means. The reservation is a
single conditional UPDATE, because two registrations racing on the last use would
both read the same count otherwise.
Accepting moves the address out of the list as it increments the count, which conserves it — somebody who registered through a one-use link can still join.
A reservation nobody accepts stays spent (max_uses is how many people a link
admits, and an account created with it was admitted), and it dies with the
invitation.
Signing in with a provider carries the invitation too, as its handle. The
provider login starts same-origin, at /api/oauth/<provider>/login, so the staged
httpOnly handle can be attached to the cross-origin hop the browser then makes —
/oauth/google/login?invitation_handle=…, which the backend peeks into the token it
holds in the session across the round trip. The token is never in that URL. Without
this, invite_only refused the Google button for exactly the links that need a
token — one constraining neither an address nor a domain — while the password form
beside it accepted the same person.
Signing in with a provider is a registration too. get_or_create_oauth_user
is the second path that creates an account, and nothing about a Google callback
looks like a sign-up — so a deployment with closed and a Google button was wide
open until both were gated. Somebody who already has an account is not re-gated:
closing registration closes registration, and locking a member out of a deployment
they belong to is not what the setting says.
The sign-up form reads the policy from the public branding endpoint and says the rule before anybody types. A form that accepts an address and then reports "that email domain cannot register" is a form that lies; the visitor has no way to know the rule exists and reads the refusal as the product being broken. That is also why the allowed domains are published: they are not a secret, and the deployment is on the company's own host.
Finding one tenant among all of them¶
GET /admin/organizations is the only surface that answers what tenants exist,
and it is app-admin only for the reason it is useful: it is cross-tenant by
construction. It answers a page of organizations with each one's member and agent
counts and its earliest owner — who to ask about it. Every owner field is null
together, for an organization whose last owner left, which is a state only the
deployment admin can fix and therefore one they have to be shown.
| Parameter | |
|---|---|
search |
Name, slug, or the owner's address. The term is text, not a pattern — 100% finds the tenant called that rather than all of them |
sort_by |
name, slug, members, agents, created_at. Anything else is a 422 |
sort_dir |
asc / desc, defaulting to newest first |
kind |
personal, team, or all. Every account is given a personal organization at sign-up, so on most deployments they are most of the list |
skip, limit |
One server page, up to 100 |
All of it happens in SQL, before OFFSET/LIMIT, and total counts what was
narrowed to rather than the deployment. That is the difference between a sort and
the appearance of one: a page sorted after it arrives claims a whole-collection
order that fifty rows cannot deliver, which is why the admin's tenant list carried
no controls at all while the route answered none (#921). The order breaks ties on
the id, so paging a column where rows share a value lists each of them once.
A column outside the set is refused by name rather than matched against nothing,
for the two reasons GET /runs refuses one: an empty page reads as this
deployment has no tenants, and an ORDER BY assembled from a query string is an
injection surface.
An app admin cannot lock the deployment out through the console¶
The self-inflicted lockout this prevents
On the single-admin install make platform-bootstrap produces, a stray click
on your own row ended administration until somebody reached a terminal.
Recovery is agenticos cmd create-app-admin <email> from a shell — the
email is a required argument.
is_active is enforced on the next request and is_app_admin is what the admin
pages read, so an app admin acting on their own row from /admin/users could
sign themselves out, drop /admin, or delete the account.
UserService.admin_update and
admin_delete refuse the self-suspend and the self-delete, and the drawer does
not offer Suspend, Demote or Impersonate on your own row (Delete stays, visible
and refused, because "why can I not delete myself" has an answer worth showing).
The API refuses acting as yourself too — nobody acting as anybody is not an
impersonation.
The deployment cannot be left with no app admin through the API at all: the one
global privilege is granted only by CLI (agenticos cmd create-app-admin) and
there is no request that clears it, so the set of app admins shrinks only by
deletion — and deleting the last one is deleting yourself, which is refused.
Removing an admin genuinely leaving is another admin's action, which is also what
keeps the audit trail readable. Recovery, if it is ever needed, is still
create-app-admin from a shell on the deployment.
That argument is about the set, and for a while the code was about one row.
Two admins deleting each other were each not deleting themselves. They locked different target rows, never contended, and both committed — zero app admins, recoverable only by writing to the database (#1208).
So an admin deletion takes SELECT ... FOR UPDATE over the app-admin set, ordered by
id, before it decides. The second request waits, re-reads the set once the first has
committed, and is refused for emptying it.
Ordered, because two requests taking the same rows in different orders is a deadlock rather than a queue. And taken on every admin deletion rather than only on an admin's: deleting a user is an administrator's action, not a hot path, and a total order is worth more than the contention it costs.
Acting as another account¶
Impersonate on /admin/users starts acting as that person from your own
browser. Nothing is copied anywhere: the console swaps the session's access
cookie for one that names the target, every page renders as they would see it,
and a banner across the top says whose account this is and who is really acting,
with the one button that ends it.
An impersonation is a session, not a bare credential. The token names a row
in sessions with impersonator_user_id set, and the API refuses it the moment
that row is ended or has expired, or the administrator behind it is no longer an
active app admin — so it stops when you press End impersonation, when the
person signs out everywhere or changes their password, when the hour is
up, or when the administrator is suspended, demoted or deleted, whichever is
first. It cannot be refreshed: the window is the access token's own, and the hour
is the ceiling rather than a renewable lease.
An open chat conversation ends with it
A chat conversation runs over a WebSocket that authenticates once, at the handshake. It now re-runs that check on every message, so ending an impersonation — or suspending the account — closes an open chat too, rather than only refusing the next HTTP request while the socket keeps answering.
The person's own devices list does not show it
An impersonation is a row under their id that an administrator holds, not a device they signed in from — so it is not in their devices list, not in the drawer's open-session count, and not their "last seen". Whether they are told at all is the setting below, and a row in that list would decide it for them.
Whether the person is told is a policy, set on this row.
notify_impersonated_users is off by default; on, the person is emailed once
when the impersonation starts, naming the administrator. The audit trail records
the impersonation either way — both the start and the end, with the session they
belong to — which is what Governance describes.
Notices, and closing the deployment¶
The announcement is one sentence with one of three styles, shown above every
page to signed-in users until they dismiss it. It is the one field on this row that
is not on the public endpoint: an announcement is an operator talking to the
people using the deployment — an upgrade window, who to ping — so it has its own
route, GET /api/v1/branding/notice, behind a session.
Dismissal is keyed on the message itself, in the browser's own storage. A flag would make the next announcement invisible to everybody who dismissed the last one; the settings row's timestamp would un-dismiss a notice whenever the deployment was renamed. The text is what changed, so the text is the key. Storage that refuses to be read or written — a privacy mode, an embedded webview — means "nothing dismissed" rather than an exception: thrown during render it would take the dashboard down for every signed-in user, and the banner still closes for as long as the page is open.
Maintenance mode holds the API shut, not just the console.
app/core/maintenance.py is a pure-ASGI middleware above the routes, so a page
somebody already has open stops working — which is the whole difference between a
maintenance mode and a banner. Its allow-list is short and is tested entry by
entry:
/health*— a readiness probe that fails during a window is an orchestrator restarting the container the operator is working in./api/v1/branding— the closed page has to be able to say what this deployment is called and why it is shut./api/v1/auth/*— an administrator has to be able to sign in while the window is open./api/v1/admin/*— and then reach the switch.- The docs and the OpenAPI schema, which serve no data.
Everything else is a 503 with Retry-After. It reads no session at all — that
would mean verifying a token above the dependency graph — so widening the path to
/api/v1/admin/* does not widen the authority: CurrentAppAdmin refuses a
non-admin there exactly as it always did.
It fails open. A gate that cannot read its own switch — a Redis blip, a migration that has not run — lets traffic through, because the alternative turns an infrastructure hiccup into a total outage nobody scheduled.
The verdict is cached in the Redis every worker already shares: written after the commit, so the switch is immediate and the cache can never advertise a state the database rolled back — published eagerly, a request that then failed left a disabled window reopening the deployment for up to the TTL. It carries a 30-second TTL as well, so a write that never reached Redis heals itself instead of leaving the deployment open through a window somebody scheduled.
And an already-open page hears about it. The branding context is resolved once by
the root server layout and never changes for the life of a page, so a window opened
afterwards left every open tab on a dashboard whose every request had begun answering
503, with nothing on screen saying why — and closing one left a tab on the
maintenance screen until somebody reloaded. GET /api/v1/branding/notice carries the
maintenance verdict beside the announcement and is polled once a minute, which is one
request for both answers rather than two that can disagree about one row.
In the console, the administrator sees a strip rather than the closed page. They are the only person who can end the window, and a maintenance mode that also hides the switch is an outage.
How much one account may take up¶
Two ceilings, both on the same row and both null by default — and null is no limit rather than "not configured". A self-hosted deployment for one company wants neither; a deployment open to sign-ups wants both, because one account can otherwise mint tenants without bound.
| Setting | Counts | Does not count |
|---|---|---|
| Organizations per account | The organizations an account owns, personal one included | Ones somebody else invited them into |
| Agents per organization | Agents the organization holds | Archived agents |
Every transition into the counted state is checked, not only a create. A ceiling
enforced on new rows alone is one that gets walked past sideways: an organization at
its agent limit archives one, creates a replacement and restores what it archived,
and an account at its organization limit is handed somebody else's through
transfer_ownership. So unarchive and transfer_ownership ask the same question
create does.
And the count is taken under a lock. Reading count(...) >= limit and then
writing is two statements, so two requests both pass the count and both insert — the
ceiling exceeded deterministically, by clicking twice. No constraint can express "at
most N rows like this", so app/db/locks.py takes a transaction-scoped advisory lock
on the subject of the ceiling: two requests about one account queue, requests about
different accounts never meet, and the lock is released by the commit or the
rollback. Only where a limit is set, so an uncapped deployment pays nothing.
Both exclusions are the point of the design rather than details of it. Being invited into ten organizations is somebody else's decision, and a ceiling one person cannot control is a ceiling that locks them out of creating their own. And archiving is how an agent is retired — a ceiling a retired agent went on occupying would make the only way back under it a delete, which takes the version history and the run attribution with it.
The refusal names the ceiling and the count it was measured against
({"limit": 5, "held": 5}), so "why can I not" is answered by the response
rather than by an administrator's memory. It is raised in the service that
creates the thing, not at the route, because the route is not the only way in.
Zero is refused by the schema: an account that may own no organizations is an account that cannot be created, since sign-up gives every one of them a personal organization.
The row¶
One row for the whole installation, guarded by the database rather than by a
convention nobody can see: singleton is unique and constrained to true, so a
second identity is an IntegrityError instead of a deployment that quietly has two
and serves whichever one a query ordered first. The write is a single
INSERT ... ON CONFLICT DO UPDATE, because a read-then-insert races itself the
moment two administrators save from two tabs.
Nothing is seeded. No row means every default, which is exactly the state of a
deployment nobody has configured — and it matters because the public branding
endpoint is unauthenticated and reached on every cold page load, so a read-through
that created a row would let a stranger provoke an INSERT.
Every write is audited into app_admin_audit_logs, naming the fields and never
their values: an announcement and a domain list are both operator text, and an
audit row outlives the request body it came from.
A refusal from this deployment always looks the same¶
Worth saying here because closing a deployment is the feature most likely to produce
one a person has never seen. app/api/exception_handlers.py puts every refusal in
{"error": {"code", "message", "details"}}:
That covers domain exceptions, schema validation,
and since #917 HTTPException too, which covers a 405, an unmatched path and the
twenty-two routes that raise one directly. Two shapes on the wire means every caller
either handles both or silently mishandles one.
A wrong-method request used to answer 500 rather than 405, on every route.
OpenTelemetry's FastAPI instrumentation derives a span name by walking app.routes,
and its Match.PARTIAL branch — which is exactly "the path matches and the method
does not" — reads .path unguarded; FastAPI 0.141 puts _IncludedRouter objects in
that list and they have none. app/core/otel_compat.py supplies, for that branch,
the same fallback upstream already uses in the branch it did guard. Still unfixed
upstream as of 0.65b0, and tests/test_otel_route_details.py fails when it is fixed,
which is when the module goes away.
Recap¶
- The deployment's identity is one row, edited from
/admin/settings, and a null column means the built-in rather than empty. signup_modeis applied in one place and gates both paths that mint an account. An invitation overrides a domain list; nothing overridesclosed.- An app admin cannot lock themselves out through the console.
- Impersonation is a session: started from the console with no token on the
clipboard, named in a banner, ended by the administrator, by the person signing
out everywhere, or by the hour. Whether the person is told is
notify_impersonated_users, off by default. - Every refusal from this deployment looks the same, whichever layer produced it.