An AI agent without a tenant is a demo. A tenant without an AI agent is a website. The link between them is the tenant_agents table, and its lifecycle is what tells you whether the tenant's chat panel today runs the thing you changed in the cockpit yesterday. This is a post about the table, the CRUD endpoints, and the two edge cases we learned about six months in.
The table
CREATE TABLE eng_tenant_agents ( id UUID PRIMARY KEY, tenant_id TEXT NOT NULL, agent_id UUID NOT NULL REFERENCES eng_agents(id), kind TEXT NOT NULL CHECK (kind IN ('site_chat','workflow','voice')), status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','active','paused','archived')), config_version INTEGER NOT NULL DEFAULT 1, config JSONB NOT NULL DEFAULT '{}', prompt_slug TEXT, mcp_tool_ids UUID[] DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE (tenant_id, agent_id, kind) );
The kind field's three values cover the three main use cases: site_chat (storefront chat panel), workflow (queue-based batch task), voice (real-time voice agent). A tenant can use the same agent across kinds — a single "order intake" agent can be both site_chat and voice.
MCP tool wiring
mcp_tool_ids is a UUID array binding MCP tools to the agent. Tools are enabled at the tenant level (via the mcp_tenant_assignments table), and the agent can use a tool only if it appears in both mcp_tool_ids AND the tenant's assignment list. The double gate matters: the agent config can in principle expose a tool, but tenant-level policy can override that if the tenant has not paid for the tool or is on a GDPR-restricted plan.
CRUD endpoints
The /admin/tenants/:tenantId/agents route family is the base. Five operations:
GET /— list, filterable by kind, status, archived inclusion.POST /— new assignment, defaultstatus='draft', config_version=1.PATCH /:id— config update, automaticconfig_versionincrement, audit-log row.POST /:id/activate—draft|paused→active, validation (prompt_slug not null, every mcp_tool_id tenant-assigned).DELETE /:id— soft delete, status →archived, row kept for audit.
Audit + versioning
Every PATCH writes an eng_tenant_agents_audit row: tenant_agent_id, config_version_before, config_version_after, config_before, config_after, changed_by, changed_at. Two goals: traceability of who changed what (compliance) and one-click restore of an earlier version (revert button in the cockpit). The revert itself becomes a new config_version, not a time travel — the row's history stays linear.
The first edge case
Agent config carries an inline system_prompt. A tenant moved an agent from site_chat to voice and missed that the prompt contained a directive to format output in Markdown. Voice synthesis read the Markdown markers aloud. "asterisk bold asterisk" came out of the speaker. The fix: switching kind now runs an explicit prompt validation that warns if the prompt contains formatting directives meaningless for the new kind.
The second edge case
Two tenants shared the same agent (same agent_id). One edited the config on its assignment. The other tenant assumed prompt_slug had moved with it. Reality: prompt_slug lives on the tenant_agents row, NOT on the eng_agents row. Tenant-A's change cannot touch Tenant-B. The confusion came from an admin UI bug; the headline now reads explicitly Editing agent X for Tenant-A.
Lifecycle as a stability guarantee
The draft → active → paused → archived four-state machine is simple, and paused is the one that pays the most. When an agent misbehaves (cost spike, hallucinations), pause is one button click that removes it from the tenant's chat panel without losing config. The on-call engineer can take a bad agent offline within a minute; reviving it (paused → active) is also one click. delete (archived) only comes once you are certain you never want it back.