Durable runs, human approvals, memory, tools, guardrails, and observability - as primitives, not plumbing you rebuild every time. From prompt to production-grade agent backend in an afternoon.
You declare what an agent may do. The runtime enforces it, makes it durable, and streams it live. Here is a complete agent:
from rya import define_agent
agent = define_agent()
@agent.on_event
async def handle(ctx, event):
ticket = await ctx.tools.call("crm.lookup", {"email": event.payload["email"]})
reply = await ctx.llm.respond(system="Draft a refund reply.", input=ticket)
# pauses the run - durably, for days if needed - until a human approves
await ctx.approvals.request(
title="Issue refund", body=reply.text,
action={"tool": "refund.issue", "input": {"ticket": ticket["id"]}},
)
await ctx.channels.send("email", {"to": ticket["email"], "body": reply.text})Every ctx.* call is journaled. So this run survives a crash, resumes exactly
where it paused, streams token-by-token to your UI, and leaves a full audit
trace - and you wrote none of that.
uvx rya create support-agent && cd support-agent
rya dev --check # validate + inspect. no keys, no database
rya events send --type message.received \
--payload '{"email":"ada@example.com"}' # run pauses for approval
rya approvals approve <id> # resume; the email is sentrya dev (without --check) starts the real thing locally: an api process
and one worker, the same two processes as production, with the working tree as
the bundle.
Offline it uses a mock model, so this just works. Set ANTHROPIC_API_KEY for
real Claude, RYA_DATABASE_URL for durable Postgres - the same agent code runs
on a laptop, a self-hosted box, and the cloud.
- Approvals actually pause the process. A human gate is not a prompt
convention -
ctx.approvals.requestunwinds the coroutine, persists, and resumes in another process by replaying the journal. The model never sees a gated tool. - The model can act, sandboxed.
ctx.llm.runlets the model call tools in a loop - and every call goes through the same permissions, scoped credentials, egress firewall, and audit as your own code. - Governance the runtime enforces, not the prompt. Permission tiers, server-side argument pinning, runtime kill switches, an egress firewall, and a grounding gate that blocks any number the agent did not get from a tool.
- Durable chat, durable jobs. Chat turns are leased and crash-reclaimed with resumable token streams; the queue runs background work in any language with retries and dead-letter. An interrupted turn is retried, not dropped.
- Coding-agent-first. Claude Code, Codex, and Cursor drive the whole thing
over a CLI (
--jsoneverywhere), an MCP server, and skills - andrya deploy --checkis a green checklist they satisfy so they ship something safe. - Yours to run. Open-core, self-hostable, offline-capable. No SDK lock-in for callers: any app talks to it over HTTP.
rya deploy --check # readiness gate: missing evals, ungated actions, secrets in the repo...
rya deploy --env prod # bundle + record an immutable version + promote
rya rollback --env prod # a pointer flip backFrom a client repo — one that installed only the rya SDK and has no database
or bucket access — the same pipeline runs over HTTP:
rya login https://rya.yourco.com --key rya_sk_…
rya publish --env prod # content-hash + upload + record + promoteThe platform rebuilds the hash from the bytes it received and refuses a mismatch,
so the content is the address either way. What publish cannot do is attest
readiness — see the honesty list below.
A deploy bundles your source, lockfile, manifest and SDK version into an immutable, content-hashed version, records it, and flips the environment's current-version pointer. New runs go to the new version; in-flight runs finish on theirs, and a version is retained while any run is still pinned to it — a run can only be replayed against the code that wrote its journal.
rya versions list # every version, newest first
rya envs list # what each environment points at
rya bundle # just the content hash — the CI "did anything change" checkGate what reaches production. A promotion gate is a server-side admission check, not a client-side courtesy: it refuses unless evidence exists that the checks passed against this exact content.
rya gate set --env prod --require-readiness --require-evals --require-provenance gitSha
rya eval --attest # files the result against the version under test
rya promote --env prod --version <id>Evidence is bound to the version, so a green eval run on a different tree cannot
admit this one. Rollback is deliberately never gated — a missing attestation must
not hold an outage open. --force works and is recorded against the version.
Bound what a workspace can consume. Quotas are admission checks too, so an exhausted budget refuses the next run rather than killing one mid-journal:
rya quotas set --max-concurrent-runs 10 --max-cost-usd-per-day 25
rya quotas show # consumption against each ceilingThe platform runs as two processes, both the same image against the same Postgres:
rya serve # api — REST/WS/SSE, auth, policy, guard, vault, console, MCP
rya worker # worker — loads the bundle, owns the journal, executes handlersThey are run modes, not microservices: one deployable, one database, no
service-to-service call — they coordinate through the queue. On the durable path
(POST /agents/{id}/turns) the api process executes no handler code, which is
what makes per-tenant isolation mean something — though two routes still bypass
that, see below. Deploy both with the AWS IaC in deploy/ or
docker compose. Serving many tenants from one deployment is an overlay on that same
file — docker compose -f docker-compose.yml -f docker-compose.multitenant.yml up -d,
which rebinds 8787 to loopback and runs one least-privilege claimer per workspace
(architecture.md).
A third mode is optional, and it is the one that means you stop declaring workers by hand:
rya supervisor # watches claimable depth; starts, scales and reaps workers
rya supervisor --plan # what it would do, and why — the real decision, no effectsWithout it a worker is started by a human, a compose file or an ECS
DesiredCount, so scale-to-zero is one-way: a key exits idle and stays unserved.
With it, work arriving is what brings the key back. Scheduling policy is ours;
only the launch mechanism is pluggable (RYA_EXECUTION_DRIVER: local, docker
or kubernetes).
Two more commands exist for the hosted posture, and both are read-first:
rya posture # is this deployment safe for untrusted tenants? all four conditions
rya orgs budget <org> --usd-per-month 500 # the billing boundary above a workspace (D29)
rya orgs reconcile # recompute every org's rollup; run it from a cron
rya posture --verify # ...and probe the substrate rather than trusting its declaration
rya keyring show # which key provider — and therefore whether a purge can crypto-shred
rya workspaces disable ws # stop scheduling, refuse claims, revoke keys. Reversible
rya workspaces purge ws # shred the key, delete objects and rows. Not reversibleTwo distributions, and they are alternatives, not halves — both own the rya
import namespace, so install one or the other:
uvx rya create my-agent # zero-install: scaffold + run
pip install rya # client SDK: build an agent in your repo
pip install 'rya-server[api,mcp,postgres,llm]' # the platform: serve, worker, console, storeA client repo needs rya and a deploy token. It never imports the runtime, never
runs a server, and never knows which deployment it is running in — ctx is
implemented by the platform, at the platform's version, which is what stops
governance being forked or pinned by a client. The SDK ships ctx type stubs so
your handlers still type-check. See packaging.
- Repository map - the codebase, module by module. Every
directory has an
AGENTS.mdwritten so a coding agent can orient fast. - Deep dive and primitives -
the full picture and every
ctx.*primitive. - MCP setup - point Claude Code / Cursor at Rya.
- TypeScript SDK - drive the platform from TS/JS: events, resumable turn streams, approvals, and the SDK-free durable job API.
- Packaging -
ryavsrya-server, and the enforced boundary between them. - End-to-end test -
python scripts/e2e_platform.pybuilds both wheels into two separate virtualenvs, authors an agent with only the SDK, and runs it on a realapi+workerpair: bundle handoff, promotion gate, durable approval, crash-resume in a different process. - Langfuse - self-host it in one compose; every run and eval score lands there, deep evals via DeepEval.
- RWAP on Rya - running a visual agent builder's workflows on Rya's durable queue (architecture + AWS).
Honest about maturity. Everything above runs today, and the durable-execution primitives are correct and tested but young — not yet load-tested at high volume. Specifically not done:
-
No managed cloud. Self-host it; that is also what makes self-hosting a residency control.
-
Publishing over HTTP cannot attest readiness.
rya publishuploads a bundle toPOST /agents/{id}/versionsand needs neither the database nor the bucket, so a client repo with only the SDK can ship. But the control plane does not import bundles (D13), so it cannot evaluate readiness and files no attestation — the response says"attested": false, and an environment gated on--require-readinesswill refuse the version. There is also norya attest readiness, sorya deploy --envfrom a machine withrya-serverremains the only way to satisfy that gate. -
The AWS mutator Lambda is a pattern, not an implementation. It returns 501 by design rather than pretending; see
deploy/aws. -
Two routes still execute handler code in the api process.Fixed (D21).POST /agents/{id}/eventsnow writes aqueuedrun — pinned to whatever the environment points at — and hands it to a worker; the caller still gets a run id synchronously and an over-quota call is still a 429 rather than a silently failed run.POST /approvals/{id}/approverecords the decision and enqueues the resume, pinned to the run's own version./rejectstays synchronous because it runs no tenant code at all.That also ends the
E_JOURNAL_DRIFTfailure this entry used to describe. The api imported its mounted entrypoint at startup, so once a bundle could be published from elsewhere the code resuming an approval could differ from the code that paused it — including by nothing more than an edit made after the api booted. The resume job is pinned torun["versionId"], so the process continuing a run is on the hash that paused it, by construction.One seam is deliberate and unchanged: a bare single-tenant
rya servestill executes inline, because there the api is the whole deployment and silently running nothing would be the worse failure.RYA_API_INLINE_WORKER=0(whatrya devand compose set) turns it off, and multi-tenant never executes. -
Crashed workers are still reportedFixed (Phase 3). Liveness is derived from heartbeat age, so a SIGKILLed worker comes backalive.lostrather thanalive— and it is still listed, because an empty worker list means scale-to-zero and a crash must not look like one. This was worse than a cosmetic defect:quotascounts live workers againstmaxWorkers, so every crash leaked a slot permanently. -
Node isolation is an accepted residual in the default posture. Process isolation plus RLS contains a buggy tenant, not a hostile one — workers share a kernel. Phase 4 built the hostile-tenant posture (no credentials in the tenant process, a gVisor sandbox, egress enforced by the network), but it is declared, not default:
RYA_UNTRUSTED_TENANTS=1. Without it, this bullet is what you have, which is the right answer for a self-host with one tenant.rya postureprints which one you are in. -
rya workeris one agent per process — the api is not.build_appno longer reads a manifest at all (D21): it learns what agents exist from published versions and environment pointers, so one control plane serves as many as the workspace has andrya publishaccepts an agent it has never heard of. The limit that remains is in the execution plane:load_agentmutatessys.pathand never unloads, so a second agent costs a second worker — not a second api, port, database or bundle store. See docs/architecture.md.rya worker --fork(Phase 3, D27) moves the import out of the claiming process into a warm interpreter it forks per run, so the long-lived process holds no tenant code at all. It does not lift the one-agent limit — a fork is still one agent on one version, which is the point of D3.Phase 5 lifted the limit on the claimer, and it was the configuration change D27 promised.
rya worker --scope tenant --forkserves every agent a workspace owns from one process: it reads each item's pinned version, materialises that bundle, and forks an interpreter for it. Five agents with two live versions each is one worker holding ten warm interpreters, not ten workers. A promotion costs no extra process, and an approval resuming on a retired version is a fork rather than a deployment. D3 is untouched: each fork still ran exactly one bundle's import. -
The fleet can span more than one box, and has not been run doing it.
rya supervisorstarts, scales and reaps workers on demand through theExecutionDriverseam, and--all-workspacesticks every tenant. Phase 4 added thedockerandkubernetesdrivers, solocalis no longer the only one — but see the gVisor caveat below.ecsis still unwritten. -
Untrusted tenancy is enforced by a refusal, not by documentation.
RYA_UNTRUSTED_TENANTS=1makes the platform check all four of: a sandbox that contains a kernel escape, a tenant process holding no credentials, egress enforced by the network, and a driver that can put the broker somewhere the tenant is not. Any one missing and it refuses to start, naming every unmet condition — because half a security boundary is not a security boundary. The refusal is reachable fromrya workeras well asrya supervisor, which was a real gap until Phase 4: the check existed and only the supervisor called it. -
What a container driver launches is a pair, and that took two phases to get right. Phase 5 found that the container drivers build the sandbox's environment from nothing — correct for the process that imports tenant code, and impossible for the process that has to open the database and be the broker. They were the same container, so a
dockerorkubernetesclaimer would have started, opened an empty local store, and claimed nothing while looking healthy. The gate refused for a phase. Phase 6 built the missing piece:rya template-host, a credential-free process that serves warm interpreters over a socket, so the sandbox container can run tenant code without the claimer having to be its parent. A launch is now a credentialed claimer container beside a credential-free sandbox container sharing an in-memory volume, and the credential boundary is a container boundary rather than a process one. The framing turned out to be off by one: nothing was wrong with either environment builder — the second container was missing. -
Two supervisors no longer double your fleet. A supervisor takes a per-workspace lease before it applies a plan; a second one goes passive, keeps observing, and logs the plan it did not apply. That last part is deliberate: "why is nothing scaling" is answered by reading a correct plan going unapplied, not by silence.
--no-leaseopts out. Two supervisors over many tenants split the fleet rather than duplicating it. -
gVisor has now been run, and running it broke something reading it never would.
scripts/verify_gvisor.shputs a realrunscsentry undercryptography,pydantic-core,psycopg,yaml,httpxandos.fork; all six work, so D23's third-party-wheel question is answered. The isolation probe was not so lucky. Its/proc/versionmarker was the literal4.4.0, copied from a fixture; a real sentry says4.19.0-gvisor. That is not a missed signal but an inverted one — a version string that is not gVisor's counts as evidence of a host kernel, so a genuine sandbox was actively refuted and the launch gate refused it. And it refused in exactly the configuration the platform ships, because the--cap-drop=ALLhardening is what makes the other signal (dmesg) unreadable. A fixture is a recording of an assumption; it confirms that assumption forever. The platform still will not claim what it cannot verify: an inconclusive probe fails the launch gate.What is still not measured is cost. The sentry runs nested in a privileged container with
--ignore-cgroups, because this host has norunsc, no passwordless sudo, and AppArmor blocks unprivileged user namespaces. Correctness is unaffected — the syscall interception is real — but the timing numbers keep their caveats, and nothing has been measured onx86_64at all.
