All use cases

Multi-tenant agent platform

Two of your customers are competitors. Neither knows the other is on your platform, and both have given your agents access to things they would never show each other. That is an ordinary Tuesday for a B2B product. It stays ordinary until an engineer writes a query for a support ticket, forgets one clause in the where, and a research assistant answers a question using a thread that belongs to someone else.

This page is for the engineer who owns that risk on a platform where every tenant gets their own agents. The pitch is narrow. Isolation and billing per tenant, without you owning the schema underneath either.

How a leak actually gets in

Almost nobody ships a leak by forgetting that tenants exist. They ship it by having isolation live in one place and reads live in forty. You add a tenant column, you write a helper that filters by it, and for six months every query goes through the helper. Then someone adds an admin export, a batch reprocessing job or a migration script that talks to the table directly, because the helper needed a request context it did not have. Nothing breaks. Tests pass. The filter is simply absent on one path.

The failure mode is that isolation was opt-in. Every read had to remember. Ringside inverts that: the tenant comes from the credential and the Customer on the request, not from a parameter your code passes, so a read that does not name a tenant does not get a broad result. It gets nothing.

Why 404 and not 403

Ask Ringside for a thread that belongs to another tenant and it answers 404, the same answer you get for an id that was never real. A 403 would have been more polite and would have leaked something: it confirms the id exists and is simply off-limits. Hand an attacker a few thousand candidate ids and a system that distinguishes those two cases will happily sort them into real and not-real. That is a map of your customer base, drawn by your own error handler.

404 gives them a flat, featureless surface with no signal to follow. It also changes what isolation is on your side. There is no allow check you can forget to write, because the negative answer is what every read returns by default and ownership is what unlocks it.

The rest of the fleet

  • Assistants, Threads and Runs, OpenAI wire-compatible. Your tenant bots get durable conversation state, function tools and a run lifecycle without you designing a schema for any of it. Swap base_url and the OpenAI SDK calls work unchanged.
  • A cap per tenant, not per platform. monthly_budget_usd on each Customer, plus its own RPM and TPM limits, so one tenant's runaway agent loop cannot starve the other twenty of throughput.
  • Margin per tenant. /v1/customers/:id/margin pairs what you billed with what the calls cost, grouped by day, model or customer. This is the report your CFO asks for the week after you sign a tenant on an unlimited plan.
  • One key to rotate. Tenancy rides on the Customer, so you are not issuing, storing and rotating a provider key per customer.

Architecture

Tenant Aacme corpTenant Bbeta incTenant Cgamma ltdRingsideCustomers + AssistantsThreads + Runsisolated per tenant

In code

# One Ringside Customer per tenant, with a spend cap.
tenant = httpx.post(
    "https://api.fightclub.pro/v1/customers",
    headers={"Authorization": f"Bearer {RINGSIDE_KEY}"},
    json={
        "external_id": f"tenant_{org.id}",
        "display_name": org.name,
        "monthly_budget_usd": 200.00,
    },
).json()

# Per-tenant assistant, scoped via tool config + system prompt.
# Assistants/Threads/Runs are OpenAI wire-compatible (base_url swapped).
assistant = client.beta.assistants.create(
    name=f"{org.name} research bot",
    model="fc:openai/gpt-4o",
    tools=[{"type": "function", "function": WEB_SEARCH_TOOL}],
    instructions=f"You answer questions about {org.product_name}.",
    metadata={"customer_id": tenant["id"]},
)

# Thread + run, pinned to the tenant for billing and isolation.
thread = client.beta.threads.create(
    metadata={"customer_id": tenant["id"]},
)
run = client.beta.threads.runs.create(
    thread_id=thread.id,
    assistant_id=assistant.id,
    extra_headers={"FC-Customer": tenant["id"]},
)

# Reading someone else's thread with your key: 404, not 403.
# Same response you would get for a thread id that never existed.

Cross-links

Get started in 5 minutes →