Psych Runtime
Guides

Serve multiple tenants safely

Psych gets tenant-correct data and per-tenant metering without owning orgs, teams or roles. It threads a Scope through every call, stamps it on every Record, and asks you.

This is the documentation for unreleased changes on main. For the released library, read v0.1.

Psych gets tenant-correct data and per-tenant metering without owning orgs, teams or roles. It threads a Scope through every call, stamps it on every Record, and asks you.

scope = psych_runtime.Scope(
    tenant="acme",  # THE isolation boundary
    principal="user-42",  # who is acting, when you know
    labels={"plan": "pro"},  # your metadata, stamped on Records, capped
)

Scope is frozen. A Scope that could be mutated after a Record was stamped with it is a Scope that could be mutated between the authorization check and the call it authorised.

Psych records principal and passes it to Policy. It never interprets it.

Pass scope= on every read

await psych_runtime.status(store, run_id, scope=scope)
await psych_runtime.report(store, run_id, scope=scope)
await psych_runtime.answer(store, run_id, scope=scope)
await psych_runtime.thread(store, run_id, scope=scope)
await psych_runtime.stream(store, run_id, scope=scope)
await psych_runtime.records(store, run_id, scope=scope)
await psych_runtime.state(store, run_id, scope=scope)

The parameter is optional so existing callers keep working while they add it. A consumer serving end users should always pass it: without it, one leaked run id, in a log line or a URL, is a whole conversation. With it, a Run belonging to another tenant raises AccessDenied.

dispatch(continues=...) is checked too: a continuation is another way to reach another Run's content and gets the same scrutiny.

psych_runtime.thread() stops rather than crossing a tenant while walking a chain. A control enforced at write time but not at read time is worse than none.

Authorization is a port

class OurPolicy:
    async def allow_tool(self, scope, tool, args) -> Decision: ...
    async def allow_run(self, scope, version) -> Decision: ...

Psych asks; it never decides. It does not model orgs, roles or permissions, because you already have an identity system and a second one that disagrees with it is worse than none.

A Policy that raises is treated as a denial, not a crash: an authorization system being down should stop work rather than let it through, and should not take the Run's whole log with it.

AllowAll is the default. It is not a security control and its name says so.

See psych-approvals for the full Decision surface.

Credentials resolve per Scope

class OurSecrets:
    async def resolve(self, scope: Scope, name: str) -> ResolvedCredential | None: ...

A Spec carries credential names, never values. Resolve fresh, or from a short-lived cache, rather than memoising forever: a credential you just revoked must stop working on the next resolve.

ResolvedCredential carries the secret plus a non-secret identity. The identity is safe where the secret is not: as a pool key, in a log line, in a repr taken while debugging. The secret is a SecretStr and the model is frozen.

Returning None is not an error. Whether a missing credential is fatal stays a decision the caller makes deliberately, so Psych never treats "not configured" as "connect anonymously".

Pool by (scope, server, credential)

The rule the design calls the bug that ends the project. McpPool and A2APool key by a frozen dataclass with the tenant and principal, the URL and transport, and the resolved credential's identity, never a name and never a value. All fields required, by name, so there is no positional shortcut that silently drops the tenant.

One pool per process is the intended shape. Isolation comes from the key. A pool per tenant just moves the same one-line mistake to whoever wires the pools up.

Scope.pool_key gives the tenancy half one definition rather than re-deriving it at each call site. Labels are excluded: two Runs differing only by label are the same tenant and principal and may share a connection.

One egress seam

class OurEgress:
    async def allow(self, scope: Scope, url: str) -> bool: ...


transport = psych_runtime.HttpTransport(policy=OurEgress())

HttpTransport owns outbound HTTP. The model client, the HTTP tool executor and the MCP client all take one rather than building their own httpx client, which is what makes it a single seam instead of a convention repeated at each call site. A control covering three of four routes is worse than none, because someone will believe it.

The policy is checked before the connection opens, so a denial never touches the network. It runs on every outbound call, including the read side of a streamed response, so keep it fast and side-effect free.

Passing an httpx client with follow_redirects=True raises. A redirect is a second request to a host the policy never saw, which turns one allowed URL into an arbitrary one.

The sandbox is the fourth route: granted network access is not a bound HTTP client handed to the program. Anything it fetches goes through a binding that itself uses this seam.

Per-tenant model clients

Runtime holds no per-run state, so one instance serves any number of concurrent Attempts. But if each tenant configures their own provider and key, build the Runtime per Attempt rather than caching one and mutating its model field. That mutation is a race with a credential in it: two Attempts sharing one object each set model for the other, and the loser sends its prompt to the other tenant's endpoint with the other tenant's key.

Building fresh removes the question rather than answering it carefully. Runtime.__post_init__ constructs a resolver and an executor and touches no IO; a model client is a handful of field assignments. Both are free beside an Attempt, which is a whole agent loop of network calls. examples/playground/backend/app/runtime_router.py is that written out.

What Psych will never own

Authentication, identity, users, sessions-as-login, organizations, teams, roles and permissions. You already have them, and owning them makes Psych a platform.

Review checklist for a cross-tenant leak

  1. Is every read path passing scope=?
  2. Is every pool keyed by scope and credential identity, never by URL?
  3. Does every outbound call go through the one HttpTransport?
  4. Does the SecretResolver take the Scope and resolve fresh?
  5. Is BlobKey constructed with a tenant, never from model input?
  6. Is any object holding a credential shared across Attempts?

On this page