One static binary that replaces Temporal + Kratos + Hydra + Keto. Plus a full Lua 5.5 runtime with 66 modules for Kubernetes, monitoring, secrets, and AI agents.
Two binaries, one project. FROM scratch-shippable, PG18 + SQLite first-class. Sizes today: assay
~12 MB, assay-engine ~19 MB (the engine grew with the auth + IdP work in v0.2.0).
assay— Lua 5.5 runtime with 63 stdlib modules (Kubernetes, Prometheus, Vault, GitHub, Gmail, OpenClaw, Tailscale, …). Drop-in replacement for 50-250 MB Python/Node/kubectl scripting containers.assay-engine— durable workflow engine (Temporal-replacement: deterministic-replay activities, signals, timers, child workflows, schedules, search attributes) + full IdP (Kratos + Hydra + Keto replacement: OIDC client + provider, passkey, JWT/JWKS rotation, biscuit capability tokens, Argon2 password, Zanzibar ReBAC, session management, admin HTTP API, dashboard panes for everything). Default builds include runtime-gated S3 history archival.
# Lua runtime
assay script.lua # Run Lua with all builtins
assay checks.yaml # Structured checks with retry/backoff/JSON output
assay exec -e 'log.info("hello")' # Inline evaluation
assay context "grafana" # LLM-ready module docs
assay modules # List all 65 modules
assay modules --json # Same list as JSON, with full per-module metadata
# Workflow + auth + dashboard server (one process)
assay-engine serve --config engine.toml
# workflow API → /api/v1/workflows + dashboard at /workflow/
# auth/IdP API → /auth/* (OIDC discovery at /.well-known/openid-configuration)
# admin SPA → /auth/consoleScripts that call http.serve() become web services. Scripts that call http.get() and exit are
jobs. The runtime talks to a deployed assay-engine over HTTP via the assay.workflow and
assay.auth stdlib modules — same binary, same builtins.
| Component | Replaces | Notes |
|---|---|---|
assay-engine workflow |
Temporal | Same define/execute_activity/wait_for_signal API |
assay-engine auth (session) |
Ory Kratos (sessions) | Cookie + CSRF + Argon2id |
assay-engine auth (passkey) |
Ory Kratos (WebAuthn) | webauthn-rs-backed register + auth ceremonies |
assay-engine auth (OIDC OP) |
Ory Hydra | RFC 7009 revoke, RFC 7662 introspect, JWKS rotation |
assay-engine auth Zanzibar |
Ory Keto / SpiceDB | Recursive-CTE walk on PG18 + SQLite |
assay-engine auth biscuit |
(Ory has nothing) | Datalog-attenuable capability tokens — built-in |
assay-engine dashboard |
Ory Console + Temporal UI | Single SPA, auth panes appear when auth is on |
assay runtime |
Python / Node + kubectl | 12 MB, 5 ms cold start, 63 stdlib modules |
| Use case | Binary | Install |
|---|---|---|
| Scripting / automation | assay |
cargo install assay-lua or download release |
| Workflow + auth + IdP server | assay-engine |
cargo install assay-engine or Docker |
assay runs Lua scripts with the full 45-module stdlib; for workflows/auth it talks to a deployed
assay-engine over HTTP. assay-engine is a standalone HTTP server with workflow + auth +
dashboard, pluggable across PG18 (default) and SQLite — both backends compiled in, runtime-selected
via config.
See docs/migration-to-0.2.0.md for the upgrade path from v0.1.x.
| Runtime | Compressed | On-disk | vs Assay | Cold Start | K8s-native |
|---|---|---|---|---|---|
| assay | ~9 MB | 12 MB | 1x | 5 ms | Yes |
| assay-engine | ~14 MB | 19 MB | 1x | 8 ms | Yes |
| Python alpine | 17 MB | 50 MB | 2x | 300 ms | No |
| bitnami/kubectl | 35 MB | 90 MB | 4x | 200 ms | Partial |
| Node.js alpine | 57 MB | 180 MB | 6x | 500 ms | No |
| Deno | 75 MB | 200 MB | 8x | 50 ms | No |
| Bun | 115 MB | 250 MB | 13x | 30 ms | No |
| postman/newman | 128 MB | 350 MB | 14x | 800 ms | No |
For comparison, the stack assay-engine replaces — Temporal server + UI + Kratos + Hydra + Keto +
their Postgres deps — typically lands at 800 MB-1.5 GB compressed across 5+ containers.
# Pre-built binaries (Linux x86_64 static, both binaries)
curl -L -o assay https://github.com/developerinlondon/assay/releases/latest/download/assay-linux-x86_64
curl -L -o assay-engine https://github.com/developerinlondon/assay/releases/latest/download/assay-engine-linux-x86_64
chmod +x assay assay-engine && sudo mv assay assay-engine /usr/local/bin/
# macOS (Apple Silicon)
curl -L -o assay https://github.com/developerinlondon/assay/releases/latest/download/assay-darwin-aarch64
curl -L -o assay-engine https://github.com/developerinlondon/assay/releases/latest/download/assay-engine-darwin-aarch64
chmod +x assay assay-engine && sudo mv assay assay-engine /usr/local/bin/
# Docker
docker pull ghcr.io/developerinlondon/assay:latest # runtime
docker pull ghcr.io/developerinlondon/assay-engine:latest # engine
# Cargo
cargo install assay-lua # the `assay` runtime binary
cargo install assay-engine # the workflow + auth serverFor semi-trusted script contexts (agent-generated scripts, review pipelines, dry-run diagnostics),
the runtime can execute scripts with every mutating builtin disabled. Activate with the global
--readonly flag or ASSAY_READONLY=1 (or true):
assay run --readonly script.lua # also: exec, YAML check mode, tool mode
ASSAY_READONLY=1 assay script.lua # env activation, same effectRead paths work unchanged: http.get (including http.client(...) wrappers), fs.read /
fs.list / fs.stat, env.get, db.query, systemd/apt status and list helpers, and all
pure builtins (json, crypto, regex, ...). Mutating builtins stay registered but raise a
clear error instead of executing:
readonly: http.post blocked (write operations are disabled in read-only mode)
Blocked surfaces: HTTP write verbs + http.serve + http.download, ws.connect, all of
shell.* / process.* / machinectl.*, fs write ops, env.set, db.execute, oci
mutators, systemd unit/machine lifecycle actions, apt mutators, tar.create /
tar.extract / compress.untar, io.popen, and io.open write modes. assay modules notes
when the mode is active, and tool-mode envelopes carry "readonly": true. For nil-ing out
additional globals entirely, combine with ASSAY_BLOCK_GLOBALS.
Read-only and approval mode decide whether a mutating operation runs. A policy file decides what
is reachable at all — which modules a script may require, which environment keys it may read,
and which HTTP hosts, methods, and paths it may call. The two compose, and a policy applies in
every mode. Point ASSAY_POLICY_FILE at a YAML file:
version: 1
modules:
allow: [assay.openstack, assay.json]
env:
allow: [OS_PROJECT_NAME]
http:
max_response_bytes: 262144
redact: [password, token]
rules:
- hosts: ["*.identity.example.com"]
methods: [GET]
paths: ["/v3/*"]
- hosts: ["*.identity.example.com"]
methods: [POST]
paths: ["/v3/auth/tokens"]
classify: readclassify: read marks a target that authenticates with a POST — an OpenStack token issue, an STS
presign — as the read it actually is, so it proceeds under --readonly instead of being refused
for its verb. With no policy loaded nothing changes.
A policy can also declare credentials, so a script authenticates without being able to read the secret:
credentials:
inventory-ro:
username: ASSAY_INVENTORY_USER
password: ASSAY_INVENTORY_PASSWORDlocal c = credential.get("inventory-ro") -- opaque placeholders, not secrets
require("assay.openstack").client(url, { username = c.username, password = c.password })The real values are substituted into the request body and headers by the HTTP layer, after the
policy has allowed the target — so printing or encoding a handle yields the placeholder, and
modules taking username/password need no changes. See docs/policy.md for
the residual-risk note.
Where read-only mode hard-blocks every write, approval mode enforces a per-operation human (or
supervisor) decision on the same catalog of mutating builtins. It is aimed at supervised remediation
contexts where each write must be individually authorized. Activate with the global
--approval-mode flag or ASSAY_APPROVAL=1 (approval mode wins if --readonly is also set):
assay run --mode tool --approval-mode remediate.lua # also: run, exec, YAML check modeWhen a script reaches a mutating operation, the run suspends and raises the existing tool-mode
approval flow — status:"needs_approval" with a resume token — carrying a descriptor of the pending
operation:
{
"status": "needs_approval",
"requiresApproval": {
"op": "http.post",
"summary": "https://api.example.com/deploy",
"index": 0,
"resumeToken": "…"
}
}The supervisor inspects the descriptor and decides:
assay resume --token <token> --approve yes # permit this one operation, re-run, suspend at the next
assay resume --token <token> --approve no # fail it with "approval: <op> denied"Each yes re-runs the script from the top: previously-approved operations execute and the run
re-suspends at the next unapproved one, so grants are single-shot and per-operation. Read paths
(http.get, fs.read, env.get, db.query, status/list helpers) run freely without prompting.
Each grant is bound to the exact call it was issued for: a SHA-256 digest over the operation, its
URL, and its arguments, reported in the approval descriptor as digest alongside the header
names in play (never their values). A replay that reaches the same index with a different target
or a different body is refused with approval: ... changed since approval rather than executing
what nobody approved. A grant carrying no digest — resume state written by an older version — is
refused too, so the check fails closed.
Because approvals are matched by the sequence index of mutating operations, a read that changes control flow between operations across re-runs can shift indices — the same replay limitation workflow engines have; suitable for supervised single-writer scripts. The digest turns a shifted index into a hard failure instead of a misapplied grant.
mcp-serve speaks stdio, which suits a client that spawns the runtime as a child process. A host
that wants the runtime in a separate trust domain — its own process, its own credentials, reached
over the network — can serve the same gated execution over HTTP:
ASSAY_API_TOKENS="$(openssl rand -hex 32)" \
ASSAY_POLICY_FILE=/etc/assay/policy.yaml \
assay api-serve --bind 0.0.0.0:8080POST /v1/run and POST /v1/resume behind a bearer token, plus an unauthenticated GET /healthz.
Both return the tool-mode envelope. unrestricted is refused unless the server opts in, exactly as
over MCP, and the server refuses to start with no tokens configured rather than quietly serving
an ungated runtime. Pair it with a policy so the transport is not the only control — see
docs/api-server.md.
assay mcp-serve runs a Model Context Protocol server over stdio so AI coding agents (Claude Code,
Cursor, Windsurf, Cline, and any MCP client) can drive the runtime directly. It speaks JSON-RPC 2.0
with newline-delimited messages per the MCP stdio transport. Point the client at the binary:
{
"mcpServers": {
"assay": { "command": "assay", "args": ["mcp-serve"] }
}
}Rather than one tool per module — which would balloon the advertised schema as modules are added — the server exposes exactly two tools and lets Lua compose the modules:
assay_run— run a Lua script (all embedded modules and builtins available) and return the tool-mode JSON envelope. Every run is gated:modeaccepts onlyreadonly(default) orapproval— unrestricted execution is not offered, so a caller can never opt out of the gate. Optionaltimeout_secsandargs. Approval gates suspend and return arequiresApprovalresume token, resumable withassay resume.assay_context— search the embedded modules and return prompt-ready Markdown docs (method signatures, env vars), the same output asassay context --no-builtins. The builtins reference is omitted by default because the calling agent's harness already carries it; passinclude_builtins: trueto append it.
A script that errors — including a write blocked by read-only mode — comes back as an MCP result
with isError: true; needs_approval is not an error. The server implements initialize,
tools/list, tools/call, and ping, and shuts down cleanly on EOF.
Install assay as a Claude Code plugin — the assay_run + assay_context tools plus a usage skill, wrapping the MCP server:
claude plugin marketplace add developerinlondon/assay
claude plugin install assayThe plugin (plugin/) declares the MCP server as assay mcp-serve, so the assay binary must be on PATH (install via cargo install assay-lua or a release binary). Every run is read-only or approval-gated; unrestricted execution is never exposed.
Once assay-engine is running with the auth module enabled, every IdP capability is reachable over
HTTP and from Lua via the assay.auth stdlib module:
# engine.toml — minimum viable v0.2.0 with auth on (0.3.1 adds env-var
# substitution for `${VAR}` / `${VAR:-default}` in any string field).
cat > engine.toml <<'TOML'
auto_enable_modules = ["auth"]
[server]
bind_addr = "0.0.0.0:3000"
public_url = "${ENGINE_PUBLIC_URL:-https://engine.example.com}"
[backend]
type = "postgres"
url = "${DATABASE_URL}"
[auth]
public_url = "${AUTH_PUBLIC_URL:-https://auth.example.com}"
admin_api_keys = ["${ADMIN_API_KEY}"]
[auth.recovery]
enabled = true
[auth.recovery.smtp]
host = "${SMTP_HOST}"
port = 587
username = "${SMTP_USERNAME}"
password = "${SMTP_PASSWORD}"
from = "Example Auth <noreply@example.com>"
starttls = true
TOML
# Inject the secrets via the environment — never bake them into the file.
export DATABASE_URL='postgres://postgres:postgres@localhost/assay'
export ADMIN_API_KEY='sk_admin_replace_me'
# Also inject SMTP_HOST, SMTP_USERNAME, and SMTP_PASSWORD when recovery is enabled.
assay-engine serve --config engine.toml
# /auth/console → admin SPA
# /.well-known/openid-configuration → OIDC discovery (Hydra-equivalent)
# /auth/login, /auth/recovery → password auth and recovery pages
# /auth/passkey/* → passkey flows
# /auth/admin/auth/* → admin HTTP API (api-key gated)When auth.public_url is omitted, auth continues to use server.public_url. Set it only when the
same engine is intentionally exposed through a separate browser-facing auth hostname; an explicit
auth.issuer still takes precedence over both defaults.
Same engine.toml works under Kubernetes — keep the TOML in a ConfigMap, project the secrets in via
env from a Secret:
spec:
containers:
- name: engine
image: ghcr.io/developerinlondon/assay-engine:0.3.1
args: ["serve", "--config", "/etc/assay/engine.toml"]
env:
- name: DATABASE_URL
valueFrom: { secretKeyRef: { name: engine-db, key: url } }
- name: ADMIN_API_KEY
valueFrom: { secretKeyRef: { name: engine-admin, key: api-key } }
volumeMounts:
- { name: cfg, mountPath: /etc/assay, readOnly: true }
volumes:
- name: cfg
configMap: { name: engine-toml }-- Use the assay-auth stdlib module from the assay (Lua) runtime
local auth = require("assay.auth")
local c = auth.client({ engine_url = "http://localhost:3000" })
local sess = c:login("alice@example.com", "hunter2")
local me = c:whoami()
local ok = c.zanzibar:check("doc", "doc-42", "read", "user", me.id)
-- Federated SSO (e.g. Google)
local redirect = c.oidc:start("google") -- returns redirect URL
-- ...user round-trips through Google...
local sess2 = c.oidc:complete("google", code, state)
-- Issue a Datalog-attenuable biscuit capability token
local pem = c.biscuit:public_pem() -- cache the engine's root pubkeyHook assay-engine up to any OIDC consumer (Immich, Grafana, ArgoCD, Nextcloud, …) by registering a
client via c.oidc_clients:create({...}) or the dashboard's OIDC Clients pane. The engine ships RFC
7009 token revocation, RFC 7662 introspection, JWKS rotation, back-channel logout, and PKCE-enforced
authorization-code flow out of the box — full Hydra parity in one process.
All 33 Rust builtins are available globally in .lua scripts — no require needed.
| Function | Description |
|---|---|
http.get(url, opts?) |
GET request, returns {status, body, headers} |
http.post(url, body, opts?) |
POST (auto-JSON if body is table) |
http.put/patch/delete(url, ...) |
PUT, PATCH, DELETE |
http.serve(port, routes) |
HTTP server with async handlers + SSE streaming (header values can be strings or arrays — array values emit the header multiple times for Set-Cookie, Link, Vary, etc.) |
ws.connect(url, opts?) |
WebSocket client (send, send_binary, recv, protocol, close); opts = {subprotocols, headers, insecure} |
dns.lookup(name, type, opts?) |
DNS lookup — A, AAAA, CNAME, MX, NS, TXT; opts = {server, timeout_ms, tries} |
dns.dnsbl(domain, list, opts?) |
Blacklist check, returns {listed, codes} |
| Function | Description |
|---|---|
json.parse(str) / json.encode(tbl) |
JSON |
yaml.parse(str) / yaml.parse_all(str) / yaml.encode(tbl) |
YAML stream/documents |
toml.parse(str) / toml.encode(tbl) |
TOML |
base64.encode(str) / base64.decode(str) |
Base64 |
| Function | Description |
|---|---|
fs.read(path) / fs.write(path, s) |
Read/write files |
fs.exists(path) / fs.mkdir(path) / fs.glob(pattern) |
File operations |
shell.exec(cmd, opts?) |
Execute shell commands |
process.list() / process.kill(pid) |
Process management |
disk.usage(path) / disk.sweep(dir, age) |
Disk info and cleanup |
os.hostname() / os.arch() / os.platform() |
OS information |
linux.cpu_stat() / meminfo() / loadavg() / proc_stat(pid) |
/proc + /sys/... readers (Linux-only) |
cgroup.cpu_stat(path) / memory(path) / pids(path) |
cgroup v2 unified-hierarchy readers (Linux-only) |
systemd.list_units(filter?) / list_machines() / journal({...}) |
D-Bus client + journal reader (Linux-only) |
env.get(key) / env.set(key, val) |
Environment variables |
sleep(secs) / time() |
Pause execution, Unix timestamp |
| Function | Description |
|---|---|
crypto.jwt_sign(claims, key, alg, opts?) |
Sign JWT (HS256, RS256/384/512, ES256/384) |
crypto.hash(str, alg) |
SHA-256, SHA-384, SHA-512, SHA3 |
crypto.hmac(key, data, alg?, raw?) |
HMAC (all 8 hash algorithms) |
crypto.random(len) |
Secure random hex string |
regex.match/find/find_all/replace |
Regular expressions |
| Function | Description |
|---|---|
db.connect(url) |
Postgres, MySQL, SQLite |
db.query(conn, sql, params?) |
Execute query, return rows |
template.render(path, vars) |
Jinja2-compatible templates |
async.spawn(fn) / async.spawn_interval(secs, fn) |
Async tasks with handles |
| Function | Description |
|---|---|
assert.eq/ne/gt/lt/contains/not_nil/matches |
Test assertions |
log.info/warn/error(msg) |
Structured logging |
36 embedded Lua modules loaded via require("assay.<name>"). Most follow the client pattern:
M.client(url, opts) then c:method(). A few utilities (ansi, url, version) are pure
functions and can be called directly off the module table.
The table below is generated by assay site/build.lua from the category: frontmatter in each
docs/modules/<slug>.md. Edit the frontmatter / docs, not the table.
| Module | Description |
|---|---|
| Monitoring & Observability | |
assay.alertmanager |
|
assay.grafana |
|
assay.loki |
|
assay.prometheus |
|
assay.sonarqube |
|
| Kubernetes & GitOps | |
assay.argocd |
|
assay.flux |
|
assay.k8s |
|
assay.kargo |
|
assay.traefik |
|
| Security & Identity | |
assay.authz |
In-process authorization engine — policy statements, grants-at-scope with typed bounds, ABAC conditions, deny-wins, asymmetric fail-closed |
assay.certmanager |
|
assay.dex |
|
assay.eso |
|
assay.openbao |
|
assay.ory |
|
assay.rauthy |
|
assay.vault |
|
assay.zitadel |
|
| Infrastructure | |
assay.apt_index |
Debian/Ubuntu apt Packages-index reader (require("assay.apt")) |
assay.crossplane |
|
assay.fs_snapshot |
btrfs / zfs subvolume snapshot wrapper for crash-consistent backup capture (require("assay.fs_snapshot"), v0.15.7+) |
assay.harbor |
|
assay.infoblox |
|
assay.openstack |
|
assay.pkg |
Package manager framework — catalog, templates, targets, plan/reconcile (v0.15.5+) |
assay.rustic |
rustic backup CLI wrapper — snapshots, backup, restore, init, check, forget (require("assay.rustic"), v0.15.7+) |
assay.servicenow |
|
assay.tailscale |
|
assay.velero |
|
| Data & Storage | |
assay.postgres |
|
assay.s3 |
|
| Feature Flags & Health | |
assay.healthcheck |
|
assay.unleash |
|
| Text, URLs & Versions | |
assay.ansi |
|
assay.url |
|
assay.version |
|
| AI Agents & Workflow | |
assay.ai-agents |
|
assay.clickup |
ClickUp REST API — tasks, lists, spaces, goals, custom fields, time tracking, and Docs |
assay.excalidash |
ExcaliDash REST API — Excalidraw drawings, collections, version history, and sharing |
assay.github |
|
assay.gitlab |
|
assay.huly |
Huly transactor REST API — document queries, transactions, fulltext search, and tracker helpers |
assay.n8n |
n8n public REST API — workflows, executions, credentials, projects, variables, plus idempotent reconcilers (v0.18.0+) |
assay.plane |
Plane REST API — projects, work items, cycles, modules, states, labels, comments, and links |
assay.workflow |
|
| AI & Agents | |
assay.neutron |
|
| Cloud & AWS | |
assay.aws-ec2 |
|
assay.aws-ecr |
|
assay.aws-s3 |
|
assay.aws-sigv4 |
|
| Container & Registry | |
assay.oci |
|
| Filesystem & Archives | |
assay.tar |
|
| Linux & systemd | |
assay.cron |
|
assay.system |
|
| Registries | |
assay.bettercontact |
BetterContact waterfall enrichment — submit a batch, poll the run, read verified emails |
assay.brreg |
Norway's Enhetsregisteret — keyless company data with websites and live employee counts |
assay.companies_house |
UK Companies House — every registered UK company and its officers, by name, number or director |
assay.contactout |
ContactOut lead data — LinkedIn enrichment and work-email lookup, budget-gated |
assay.cvr |
Denmark's CVR via the public cvrapi.dk gateway — keyless company lookup by name, VAT or phone |
assay.edgar |
SEC EDGAR — US public-company registry, filings and full-text search |
assay.email_verify |
Email verification — syntax, MX over DoH, list signals, and a live SMTP probe |
assay.gleif |
GLEIF LEI registry — global legal-entity search, keyless and free |
assay.lead_provider |
The lead-data contract — uniform records with provenance, and the gate every paid call goes through |
| Stdlib | |
assay.shell |