
Zetesis Portal and PayloadAgents: how we built our SaaS
The site you are reading runs on Zetesis Portal. It is our platform: the system we use to turn an organization's knowledge into something that can be searched and asked. And its core is open source: PayloadAgents.
This post is the full tour: how it is built as a SaaS, how it deploys itself, and why we open-sourced its core.
Real multitenancy
Zetesis Portal is multi-tenant: each organization has its isolated space —content, documents, agents, search profiles, bot installations, and metrics— and only shares what should be shared: users, taxonomies, and media.
- Scoping is enforced in Payload with the multi-tenant plugin and per-tenant roles:
tenant-admin,tenant-viewer,tenant-chat-user,tenant-mcp-user. - A tenant can be public —like this blog— and its contents are mixed in with those of your organizations only if you have access.
- Integrity is validated on the server: no relationship can point to objects from another tenant, even if someone requests it directly from the API.
The content model: Payload CMS, R2, and the PDF parser
Everything in the portal is a Payload collection. The main ones:
- Posts: articles with localized richText (es/en), folders, cover image, and taxonomies. Catalog content can be static and seeded —like this very post—.
- Books: full books split into markdown chapters.
- Taxonomies: hierarchical trees shared across tenants, with breadcrumbs.
- Media and Documents: the files, with type validation via magic bytes on the server.
Binaries live in Cloudflare R2 (S3-compatible): the storage plugin uploads media and documents there when they are created, and the bucket is not public —the application serves the files with a cached S3 client, and the worker that parses PDFs downloads the binary through an internal endpoint, with no signed or public URLs—.
When a PDF lands in Documents, Payload enqueues zp.documents.parse: a worker downloads the file, validates type and size, sends it to LlamaParse, waits for the result, and writes the markdown back into the document. From that moment on it is chunked and indexed like any other content. Permanent errors go to the DLQ and, if the worker is not configured, parsing runs inline as plan B.
Identity: OIDC with a Keycloak that is a product of its own
Authentication is OIDC against Keycloak, but with a twist: Keycloak was extracted from the portal and lives as an independent deployment —Zetesis-Auth—, with its own image, chart, and release cycle. The same pattern we reuse in other ecosystems.
- Login with PKCE, account linking and RP-initiated logout.
- Three layers of permissions: global role, per-tenant roles, and entitlements from the Stripe subscription.
- Each tenant maps to a Keycloak organization: login syncs memberships and roles automatically.
Agents: Agno as the runtime, agents as data
Agents are not hard-coded: they are documents in a collection —prompt, model, tools, budgets— that the runtime downloads and serves.
- The runtime is Agno on FastAPI: sessions in Postgres, AG-UI protocol with streaming, and hot reload via
LISTEN/NOTIFYfrom Postgres —changing an agent in the admin updates it on all replicas without a redeploy—. - Each agent gets its tools from an in-house MCP server with 13 search and synthesis tools over the indexed content.
- The search profiles define which collections, taxonomies, and folders each agent can query, with rerankers and lenses for re-scoring. An agent can have several profiles and choose which one to use depending on the question.
- Channels: Telegram, WhatsApp, Discord, and Teams —Teams with its own published package, JWT verification, and Adaptive Cards—.
And the integration works both ways: any external MCP client —Claude Desktop, Cursor— can query the portal with per-user search tokens, scoped by profiles.
Channel linking is done from the integrations page: you pick the channel, a one-time token is generated, and the bot connects your Telegram, WhatsApp, or Discord account with your portal user. Each tenant can install as many bots as it needs.
Teams takes a different path. There is an in-house package —agno-microsoft-teams, also published on PyPI— that connects Agno agents with Microsoft Teams, with JWT verification against Bot Framework and Adaptive Cards; but its integration is more manual and less automatable: installation and account linking do not go through the automatic token flow used by the other channels.
Model gateway and total observability
All AI traffic goes through our own LiteLLM gateway:
- Virtual keys per agent with their own budget and rate limits; the real provider key travels per request and is never stored unencrypted.
- Curated catalog —
chat-estandar,chat-premium,razonador,economico— that hides the real model from consumers. - Closed egress: the runtime can only talk to the gateway; provider keys are not even mounted in the pod.
- Real cost per run: the gateway injects the cost into the stream and every run is recorded in a ledger (
llm-usage-events) with tokens, latency, and cost source —real gateway or table-based estimate—. - Langfuse self-hosted with nested runtime+gateway traces in the same OTel trace, and labels per tenant and agent. Prometheus + Grafana for the platform, with a dedicated gateway dashboard.
- Logs arrive on their own: each pod reports them automatically —Promtail and an OTel collector forward them to Loki in the central observability hub—; there is no need to set up log shipping service by service.
Payments: the lessons from Escohotado turned into a package
The subscription system was born from learning from a real mistake. In the first version —the Escohotado one— subscriptions were materialized in a JSON inside the user and webhooks had no deduplication: a Stripe retry landing on another pod duplicated subscriptions and permissions.
The current version is payload-betterauth-stripe, our package on top of better-auth:
- Subscriptions as a first-class collection; permissions are recalculated on every access instead of being copied onto the user.
- Webhook deduplication in Postgres: each event is claimed with an
INSERT ... ON CONFLICT DO NOTHINGbefore being processed —truly multi-pod— and the table is created by an explicit, auditable migration. - Stripe mirror plans with entitlements per plan, and a daily token limit per plan that turns into a 429 when it runs out.
The queue: Redis Streams today; JetStream, parked
Heavy jobs —like PDF parsing with LlamaParse— run on an in-house queue (nexus-queue) with versioned envelope, idempotency, retries, and DLQ on Redis Streams and Taskiq.
The next iteration exists on a branch: a migration to NATS JetStream with its own runtime without Taskiq —receivers with declarative backoff, DLQ via advisories, TypeScript↔Python conformance suite— and the documents worker already ported. It is not merged: we parked it as a risk-benefit decision —moving the production queue is not worth what it gains—. It remains the example of how we treat infrastructure: spec first, conformance next, merge when it holds up.
Open-core: what we open and how it all fits together
PayloadAgents is the open core (MIT, public); Zetesis Portal is the product (private). The relationship is not theoretical:
- 11 npm packages
@zetesis/*and 4 Python builders on PyPI: indexing, Typesense, taxonomies, agents, metrics, documents, queue, chat UI, MCP. - The portal consumes them from the git submodule —workspace for TS, editable paths for Python—; npm and PyPI are for external consumers.
- Real publishability: the packages compile their types without depending on the application —that is the piece that makes open-core possible rather than a promise—.
Release-please: from commit to production without touching anything
The release chain is closed end to end:
A conventional commit opens the release PR; on merge, packages, images, and chart are published; and a job automatically opens the bump PR in the GitOps repo. Merging is deploying.
CI/CD: Mileto, alejandro, and the homelab
Operations live in Mileto-Infra-GitOps, the GitOps repository that operates the platform: ArgoCD app-of-apps over Talos clusters in the homelab —alejandro for product, escipion for platform, with secrets in Infisical and External Secrets—, plus doco-cd for the machines outside Kubernetes.
- Production on a single node of the alejandro cluster: web and agent-runtime with HPA, LiteLLM gateway in the chart, data in a shared namespace —Postgres 17 CNPG, Typesense, Redis—.
- Ephemeral PR previews: each pull request spins up its own namespace, database, and domain, with automatic cleanup after 24 hours.
- Automatic backups: continuous WAL + daily Postgres base backup to offsite MinIO, Typesense snapshots, and logical dumps, with a restore runbook.
- CI on self-hosted runners inside the cluster itself, with strict manifest validation and secret scanning.
Conclusions
Zetesis Portal is proof that all of this holds up in production: we use it ourselves every day, with customers on top of it. PayloadAgents is our way of saying there is no trick: the core is out in the open, with its releases, its tests, and its decision history.
Building a SaaS like this is not about picking technologies from a list: it is identity, agents, payments, queue, gateway, observability, and GitOps working as a single piece. That is what we build —and what we can build for others—.
Payload plugins and agent runtime, published as @zetesis/ packages.
TypeScriptMore real-world cases

Flowgraph: a flow engine built spec-driven

Building a homelab from scratch: Kubernetes and GitOps - Homelab (03/06)

Antonio Escohotado's legacy, explorable with AI

Classifying documents into 77 categories without training a model

From standard to graph to project
