Reflex SDK
The typed surface agent-authored Reflex code calls, and the compiler gate that checks it.
@stndrds/reflex-sdk is the surface a Reflex is written against. It carries one definition per
capability, shared by three consumers, so a capability cannot mean two different things:
- the agent toolchain, which turns each definition into a tool an agent can call;
- the Reflex gateway, which uses each definition's kind to decide what a rehearsal captures;
- Reflex code, whose argument types are inferred from those same definitions.
The capabilities
Five, all scoped to a single record: get_record, search_records, create_record,
update_record, delete_record. Bulk and unscoped tools are excluded on purpose — one bulk
call mutates many records and would be reported as a single effect, which would make an
effects report understate what happened.
The entry point
A Reflex version is a map of source files with index.ts as its entry. index.ts must default-
export the function the runner calls, and defineReflex types it:
import { createReflexClient, defineReflex, isCapabilityError } from "@stndrds/reflex-sdk";
export default defineReflex(async (input: { recordId: string }) => {
const contact = await createReflexClient().objects("contacts").get(input.recordId);
return isCapabilityError(contact) ? null : contact.record.label;
});The runner passes the invocation's payload as the single argument and keeps whatever the handler returns. Nothing validates the payload's shape — declare it and narrow it yourself.
Tenant types
Object and attribute types are not in the package. They are generated per tenant from that
tenant's live schema and augment the TenantObjects interface the package declares. Load
them and object names, attribute names and select option values are all checked:
import { createReflexClient } from "@stndrds/reflex-sdk";
const client = createReflexClient();
await client.objects("contacts").update(recordId, { status: "client" });Without the generated file there are no object names to pass, so the call does not compile.
Formula and rollup attributes are computed server-side and are omitted from the writable shape, so writing one is a compile error rather than a silently ignored field.
Results
Every write can return one of three things, and all three are in its type: the result, a
{ captured: true } marker when the run is a rehearsal, or { error } when the capability
declined. Use isCaptured and isCapabilityError to narrow. Reading recordId off a
rehearsal result is precisely the bug the union exists to prevent.
The gate
Before a version is promoted, the server compiles it against the SDK and the tenant's
generated types with strict on, no DOM and no Node types in scope. A Reflex that compiles
with the tenant types removed touches no tenant data, which is the condition for shipping it
from a codebase rather than generating it per tenant.
What a promoted version can do with this
createReflexClient() resolves its transport from a global the runner sets before a Reflex
executes. The gate above proves a version compiles against the tenant's real types; How a
Reflex runs, further down this page, covers what happens once it is promoted and an agent
calls it.
Not the same SDK as the schema builders
@stndrds/reflex-sdk is a separate package from the SDK that documents this
framework's schema builders (object(), attribute(), view()). That SDK is what you use to
define your application's schema in code; this one is what a Reflex calls at runtime against
an already-defined schema. Neither supersedes the other.
Bundling
A version is compiled into a single JavaScript file before anything can run it. The bundler resolves every import at build time, so the runtime never installs a package and never resolves a module — a Reflex starts as fast as reading one file.
Only @stndrds/reflex-sdk may be imported. Relative imports between a version's own source
files work normally; anything else — a Node builtin, a package that happens to be installed
next to the server — is refused by name, with the name in the error.
An artefact that still carries an import after bundling is rejected even when the import resolved. There is nothing on the other side of it at run time.
Egress
The bundle is then parsed and checked for anything that could reach the network without going
through the gateway a runner installs: fetch, XMLHttpRequest, WebSocket, require,
eval, new Function, dynamic import(), .constructor, and any use of globalThis,
window, self or global.
A tripwire, not a boundary
This is a syntactic check on the parsed program, not a proof. Three rounds of hardening kept
finding new ways to reach Function without tripping any forbidden name, and four are known
to stay open, pinned as tests: a local alias assigned before indexing, a two-hop [][k][k]
chain, (class{})[k], and a reference to a named declared function. A type-aware pass would
close two of those and not the general case, because a computed key's runtime value isn't
answerable from types. The check over-refuses on purpose — a local variable innocently named
fetch is rejected — which is the direction to err in when the check cannot be made sound. It
catches mistakes and naive code on every promotion, and is worth having for that. It does not
stop an adversary, and nothing here makes a bundle safe or prevents egress. The control that
would is network confinement of the sandbox a Reflex runs in, and whether the provider
supports it is unverified today — a prerequisite for arming any Reflex that can reach outward,
not a nice-to-have.
Each accepted version is stored with a sha256 digest of its bundle, which is what identifies
the artefact a runner has already cached.
How a Reflex runs
A promoted version executes inside a sandbox that belongs to the workspace, not to the Reflex. The runner reconnects to that sandbox, writes the artefact under its digest if it is not already cached there, and starts the code with its input on standard input.
Every capability call the code makes leaves the sandbox on standard output and is answered by the gateway on standard input. Three consequences follow, and they are the point:
- The sandbox makes no network connection. A Reflex needs no outbound access to reach the platform, so a run cannot leave the machine by any route the runner provides.
- The sandbox holds no credential. The run token stays in the API process and is attached to each call there. There is nothing inside the sandbox worth stealing.
- The workspace, and the acting identity, come from the token — never from anything the code says.
A sandbox that the provider has reclaimed is not a failure: the artefact lives in the database, so the runner provisions a new one and writes it again.
An agent invokes a Reflex with one tool, call_reflex, which takes the Reflex's name and a
payload and returns what it produced. That is the only Reflex tool an agent holds — reading or
writing a Reflex's own code is a separate capability this milestone does not expose to an agent.
What this still does not do
Nothing above makes a bundle safe. Reflex code runs in a micro-VM whose network confinement is unverified, and the static egress check at promotion is a tripwire with four known open routes. M0 gives a Reflex no outbound capability at all, so there is nothing to gate yet — but verifying confinement is a prerequisite for the milestone that adds one, not an optional extra.
There is still no trigger: a Reflex runs when an agent calls it with call_reflex, and nothing
else starts one. Ingest URLs, schedules and record events come later.
Two more limits worth knowing before relying on this. First, the sandbox itself is shared across API instances by design — it is a row in Postgres, not a value one process owns — but the count of runs currently attached to it is kept in each process's own memory. When one instance cannot confirm a run's sandbox actually terminated, it quarantines and destroys that sandbox once nothing IT knows of is still using it; it has no way to see a concurrent run another instance is serving against the same sandbox, so that other instance's run can still be killed out from under it. Second, the bridge that carries capability calls answers them one at a time per run — concurrent capability calls from the same run queue behind each other, which bounds how much gateway load one run can generate.