Skip to content

URLs of a running Stage

A play session serves everything a caller needs on one port: the model’s own API, the Scalar reference and the Chronicle Workbench, all on 9090. Everything below assumes it was published as itself:

Terminal window
docker run --rm -p 9090:9090 -v "$PWD":/eventmodel cratis/stage:latest

The semantic engine is the default. Its admitted API surface and in-process read-model browser are described under Semantic execution. The legacy EventModel surface below remains available with Stage__Runtime__Engine=eventmodel (or --engine=eventmodel on the Host executable). That engine is deprecated, logs one warning at startup, and will be removed in Stage v6.

Nothing needs to be discovered by hand — start at the Scalar reference and click.

Two modes. Everything on this page describes the sandbox (the default). A host started in published mode serves only the frontend, the model’s /api operations, Arc’s /.cratis/* operations and a handful of /stage/* documents; every route in the table below answers 404 there, and the tooling routes are not mapped at all.

RouteSandboxPublished
Frontend and /api/... model operationsyesyes
/.cratis/* Arc operationsyesyes
/stage/status, /stage/scene, /stage/routes, /stage/locales, /stage/strings/{locale}yesyes
/stage/model-referencesnoyes
/scalar, /openapiyesnot mapped
/workbenchyesnot mapped
/read-models, /stage/read-modelsyesnot mapped
/stage/load, /stage/semantic/* (admission, specification runs)yesnot mapped
/stage/packagesyesnot mapped
URLWhat it is
http://localhost:9090/scalar/v1The API reference. A Scalar UI over the model’s operations, with a request builder that executes them. http://localhost:9090/scalar redirects here.
http://localhost:9090/openapi/v1.jsonThe OpenAPI document behind it. Framework infrastructure operations and Stage’s own /stage and /workbench endpoints are stripped, so it describes only the model’s own commands and queries.
http://localhost:9090/workbenchThe Chronicle Workbench — event stores, event types, observers and read models for the session.

The Scalar page is the authoritative list of what a session exposes: it is generated from the endpoints Arc actually mapped for the model that was loaded. Reached through a path-prefixed reverse proxy that sends the X-Forwarded-* headers, both it and the OpenAPI document’s servers entry report the public address — see Behind a reverse proxy.

Every command in the model becomes a POST, at a route built from where the command sits in the model — module, feature, any sub-feature, slice, then the command name, each normalized using Arc’s route conventions. The canonical route always includes the command name, even when it is the only command:

POST /api/<module>/<feature>[/<sub-feature>]/<slice>/<command>
POST /api/<module>/<feature>[/<sub-feature>]/<slice>/<command>/validate

For an Invoicing module with an InvoiceManagement feature holding a RegisterInvoice slice and command, and an Adjustments sub-feature holding an ApplyDiscount slice and command:

Terminal window
curl -X POST http://localhost:9090/api/invoicing/invoice-management/register-invoice/register-invoice \
-H "Content-Type: application/json" \
-d '{"invoiceNumber":"INV-1042","customerId":"11111111-1111-1111-1111-111111111111"}'
curl -X POST http://localhost:9090/api/invoicing/invoice-management/adjustments/apply-discount/apply-discount \
-H "Content-Type: application/json" \
-d '{"invoiceId":"…","percentage":10,"reason":"Loyal customer","requiresReview":false}'

The /validate variant next to every command takes the same body and runs the command through the pipeline without executing it — that is what a client’s form validation calls. For semantic commands, this dry run enforces admitted modeled validation and authorization without committing facts. The legacy EventModel path does not enforce those modeled rules or policies.

For produced event mappings, $context.tenant is the portable tenant ID translated from the command’s Arc tenant, and $context.causedBy.subject, .name, and .userName resolve from the caller’s identity (subject uses the same ID as $context.identity.id). An anonymous caller has no ID, so identity-sourced event properties receive empty strings. Arc’s named Default and unresolved NotSet tenants both map to the zero-GUID string. A request without a tenant header uses this default identity, matching reducers in Chronicle’s default namespace. Chronicle’s explicit NotSet namespace remains an empty portable tenant; it is not Arc’s no-header request case. Named tenants keep their exact values and casing. Empty named tenants and zero-GUID names are rejected as ambiguous. Tenant mappings remain text; use a string-backed tenant concept.

Stage reports a rejected Chronicle append as an unsuccessful command, not as a successful response with no read-model changes. Constraint rejections return validation results with the constraint name and message; schema or other append failures return an error result. A produced property sourced from an unsupported $context path also fails the command instead of appending a partial event.

The body is the command’s modeled payload, and the reply is Arc’s standard CommandResult envelope:

{
"response": {
"invoiceNumber": "INV-1042",
"customerId": "11111111-1111-1111-1111-111111111111"
},
"correlationId": "a870543c-f442-4ee9-913e-25cecb7b9c7a",
"isSuccess": true,
"isAuthorized": true,
"isValid": true,
"hasExceptions": false,
"validationResults": [],
"exceptionMessages": []
}

Every read model in the model gets two queries by convention — one for a single instance and one for all of them — named Get<ReadModel>ById and All<ReadModels>, kebab-cased into the route:

GET /api/<module>/<feature>[/<sub-feature>]/<slice>/get-<read-model>-by-id?id=<id>
GET /api/<module>/<feature>[/<sub-feature>]/<slice>/all-<read-models>

For the InvoiceList slice and its InvoiceListReadModel:

Terminal window
curl "http://localhost:9090/api/invoicing/invoice-management/invoice-list/all-invoice-list-read-models"
curl "http://localhost:9090/api/invoicing/invoice-management/invoice-list/get-invoice-list-read-model-by-id?id=$ID"

Both queries read the documents the modeled projection built in the session’s Chronicle, using the first 500 instances of the read model. All<ReadModels> returns that window. Get<ReadModel>ById searches the same window for an instance whose identity matches id (compared case-insensitively) and returns data: null when none does, including an instance that exists beyond the first 500.

Snapshot queries return one-shot results. A live (observable) query declared in the model returns a subject that replays the current result and emits again whenever a committed command or outside fact changes it: a plain GET can answer 202 before the first emission, ?waitForFirstResult=true waits for a 200 result, and Accept: text/event-stream subscribes to SSE updates. Conventional All<ReadModels> and Get<ReadModel>ById queries stay one-shot. Generated React routes use the wait flag and subscribe over SSE.

Every query the model declares is also served under its own name, so query AllWorkItems => WorkItemSummary[] answers at .../all-work-items. A query that shares a conventional name takes that name’s place. A query the model narrows with by only returns what its argument names. Given this declaration:

query CommentsForWorkItem => observable CommentView[]
by workItemId WorkItemId

the query only returns instances whose workItemId matches the argument:

GET /api/<module>/<feature>[/<sub-feature>]/<slice>/comments-for-work-item?workItemId=<id>

The match compares the read-model property with the argument case-insensitively, and falls back to the instance identity when the read model does not carry the property. A missing or empty argument returns an empty result, never the whole read model. All<ReadModels> keeps returning every instance, but when a caller supplies a modeled by parameter such as ?workItemId=<id>, it applies the same narrowing.

Each answers with Arc’s QueryResult envelope. On success, isAuthorized is true and data holds the instances — an array for All<ReadModels>, a single object for Get<ReadModel>ById. Each instance carries its identity as id next to the properties the projection wrote:

{
"data": [
{
"id": "8f14e45f-ceea-467a-9c2b-1b7f2ec2a1c1",
"name": "Test item"
}
],
"isAuthorized": true
}

Pass the id a previous response returned, URL-encoded — do not assume it is a GUID. Stage reads the identity from the kernel document and normalizes it to text:

Kernel identityReturned id
StringThe string, unchanged (it can be empty)
NumberInvariant text, so 42.0 is 42
BooleanTrue or False
Composite object of scalar valuesThe values joined with _, ordered by property name, so {"region":"west","number":42} is 42_west

When a document has a lowercase id, only that field binds the identity; a modeled property named Id or ID stays an ordinary property. A document without a lowercase id falls back to a legacy uppercase Id.

A document Stage cannot read fails the whole query with InvalidStageReadModelDocument rather than being silently dropped from the result. That covers a document that is not a JSON object or is not valid JSON, a missing or null identity, and an identity that is an array or a composite containing a null or array value. Arc returns it as an error result (hasExceptions, HTTP 500).

Modeled query authorization is not enforced yet. Stage does not yet receive Screenplay’s executable query authorization, so a modeled authorize on a query is not evaluated: a session’s queries allow anonymous access and answer anyone who can reach the port. The session is a disposable sandbox holding only the events it appended, but treat it as unprotected — bind its published ports to loopback or reach it only through an authenticated proxy. Filters in Arc’s query pipeline can still reject a query; a rejected query answers 403 with isAuthorized: false and no data, without reading Chronicle.

Every query endpoint also accepts the HTTP QUERY method, carrying its arguments in a JSON body instead of the query string — useful when arguments are too large or too structured for a URL. The alternate method has the same outcomes as GET: the same data, the same errors, and the same pipeline rejections. Set Cratis:Arc:GeneratedApis:EnableQueryHttpMethod to false to disable QUERY on both canonical paths and compatibility aliases without disabling GET. Canonical query names are always included. OpenAPI describes the GET operation; Arc keeps the alternate QUERY transport out of API description while mapping it at the same canonical path when enabled.

The scene served at /stage/scene attaches a route to every element that reads data. An element declared as data AllWorkItems gets the route of AllWorkItems and nothing else, even when another query such as WorkItemById returns the same read model. An element that names only a read model gets that read model’s All<ReadModels> route. /stage/routes maps each query and command by its own name.

One fallback is defined: an unkeyed collection binding (data AllWorkItems with no by) whose query has no endpoint of its own reads its read model’s All<ReadModels>, which answers the same rows. A keyed binding such as data CommentsForWorkItem by workItemId is never widened to the unfiltered collection.

When a name otherwise matches no endpoint, or several distinct ones (the same query name in two slices, for example), Stage attaches no route rather than guessing. The element then carries routeStatus (unresolved or ambiguous) and a routeDiagnostic naming the candidates. /stage/routes leaves an ambiguous name out and lists it under diagnostics.

Legacy aliases preserve actual historical routes, not guessed command-suffix routes:

  • A feature/sub-feature with one command historically mapped POST /api/<module>/<feature>[/<sub-feature>] and its /validate variant. For the singleton RegisterInvoice example, the aliases are /api/invoicing/invoice-management and /api/invoicing/invoice-management/validate.
  • With multiple commands at the old feature location, Arc included each command name. Unique old command-name routes and their validation routes remain aliases.
  • Conventional query aliases omit the slice, for both GET and QUERY, when uniquely owned.

An alias is registered only if its HTTP method and normalized path uniquely identify an operation across the complete generated Stage command/query surface. Aliases execute the same Arc handler with the same metadata semantics, not a redirect. They are excluded from OpenAPI and introspection advertises only canonical routes.

For example, Orders / Checkout / PlaceOrder / DoIt and Orders / Checkout / CancelOrder / DoIt remain distinct commands and have stable canonical routes:

POST /api/orders/checkout/place-order/do-it
POST /api/orders/checkout/cancel-order/do-it

The ambiguous legacy POST /api/orders/checkout/do-it and /api/orders/checkout/do-it/validate are not registered; both return 404, never the first or last declared command. Changing declaration order does not change ownership.

A canonical collision is different: Stage refuses to start with AmbiguousStageHttpSurface. Diagnostics identify the HTTP method, normalized path, operation kinds, slice IDs, and qualified artifacts. This admission also rejects case/kebab/sanitization collisions, omitted collection/type identity collisions, cross-depth execute/validation collisions, and a canonical route that would take over another operation’s legacy URL—even an ambiguous legacy URL. The entire modeled surface is admitted before providers, dynamic CLR types, modeled endpoint mapping, or Chronicle connection; a rejected model does not publish a partial modeled surface. This is an HTTP-host boundary, not a compiler restriction on equal command names in different slices. No collection segments, hashes, or order-based suffixes are invented to resolve conflicts.

Two endpoints list what the session exposes, for tooling that would rather not parse OpenAPI:

URLWhat it returns
http://localhost:9090/.cratis/commandsEvery command: name, namespace, type, and its payload schema.
http://localhost:9090/.cratis/queriesEvery query: name, fully qualified name, read model type, and its arguments schema.

The route fields use the same host-owned generated-API options as endpoint mapping: canonical paths include the slice and do not include an extra /stage segment. Command CLR names and fully qualified query identities are unchanged. Compatibility aliases do not add extra introspection entries.

Arc’s own infrastructure is served under /.cratis on the same port. It is deliberately absent from the OpenAPI document, but it is there:

URLPurpose
/.cratis/meThe current identity. Unauthenticated in a sandbox session, so it answers 401.
/.cratis/users, /.cratis/tenantsDevelopment identity endpoints — the users and tenants a client can switch between.
/.cratis/identity-details/schemaThe schema of the identity details the app provides.
/.cratis/queries/wsWebSocket transport for observable queries (a plain GET fails — it needs the upgrade).
/.cratis/queries/sse, /.cratis/queries/sse/subscribe, /.cratis/queries/sse/unsubscribeServer-Sent Events transport for observable queries.
/.cratis/queries/healthObservable query health. Answers 202 until it has produced its first result.

These observable transports serve the model’s live queries; snapshot queries and the conventional All/ById queries remain one-shot.

Two request headers are honored on every call: X-Correlation-Id to correlate a call with what it caused, and X-Tenant-Id to pick the tenant.

The session’s event store is a real Chronicle event store, and the Workbench is the window into it — event types, event streams, observers, projections and read models:

http://localhost:9090/workbench

It is served on the model’s own port, alongside everything else, so publishing 9090 is all a session needs. Nothing has to be signed into: a play session’s kernel runs with authentication turned off, because it is embedded in the container with its only client and is thrown away with it.

Find the session by its generated name. Each session gets a Docker-style event store name (brave-mendel, nifty-turing, …), printed in the host’s startup log and shown in the Workbench.

Reached through a path-prefixed reverse proxy, the Workbench follows the prefix like the rest of the session — the Stage tells the page where it is being served from, so a session proxied at https://studio.example.com/api/play/<session>/ has its Workbench at …/api/play/<session>/workbench.

Runtime behavior depends on the selected engine. The default semantic engine enforces admitted modeled command validation and authorization, including authenticated-caller checks, roles, claims, and logical policy combinations, before producing facts. Invalid or denied commands return validation or authorization failures in Arc’s CommandResult without appending facts. /stage/semantic/admission reports supported and unsupported artifacts; unsupported rules or policies are refused rather than ignored. Described command handlers still require a target implementation. Sandbox persona identities are for playing the model, not a production security boundary.

The legacy HTTP surface above and the separate verification and rendering paths retain these limits:

  • A legacy EventModel command accepts its payload, evaluates its modeled produces mappings, appends the resulting facts to Chronicle, and echoes the payload as the response. This legacy path does not enforce modeled command validation rules or authorization policies and must not be treated as a production security boundary.
  • A legacy EventModel query reads the first 500 projected documents of its read model from Chronicle and returns them, or the one matching id. Modeled query authorization is not yet enforced; only Arc query-pipeline filters can reject a query. A document Stage cannot read fails the whole query with InvalidStageReadModelDocument.
  • The specification runner checks modeled facts and expectations, but that verification is model-level. It is not a substitute for executing every slice through the runtime or a rendered application.
  • The separate legacy syntax-based renderer writes reviewable backend source. It preserves role-only alternatives and authenticated-only authorization exactly on each generated query method. Unsupported authorization raises STAGE-AUTH-001 and faults the render operation. Because output is currently written directly, a failed target is unsafe and incomplete: stale files, including a prior copy of a blocked artifact, can remain physically present. The advisory .stage-render-failed marker does not disable or delete them. Frontend/UI rendering and other model constructs remain incomplete.

Use a session to explore routes, payloads, appended facts, and schemas. Use the specification runner for modeled expectations, and review/build the renderer output when evaluating generated application behavior.