Build a microservice for XR Workspace
The workspace is the only place people log in. Your service never builds auth, signup, or sessions — it verifies a short-lived signed identity token, scopes every read and write to that user, and speaks one uniform contract. In return it appears in the workspace as a native product: navigation, badges, notifications, and search included.
The model
- Auth lives at the workspace level.Users sign in once. The workspace resolves who they are and what they're entitled to, then calls your service with a per-service API key and a signed Subject— the user's normalized email, and (when granted) their company id.
- Content access lives at your service's level. You own your data and your domain rules. Every read and write is scoped to the verified email; a row is visible only if that email is on it (trimmed, case-insensitive). Cross-customer access must be impossible.
- The workspace is the only hub. Browsers never call your service directly, and services never call each other. Events you emit go to the workspace; facts you consume arrive from it. No point-to-point coupling, ever.
One spec for the whole platform
The entire integration surface — the workspace's service-facing endpoints and every microservice's machine API — is described by one canonical OpenAPI 3.1 document. It's the source of truth: the explorer below, the auth table, and the error taxonomy all render from it, and a CI conformance gate keeps it matched to the deployed route code. Import it straight into Postman, Swagger UI, or a code generator:
- GET /api/v1/openapi — JSON
- GET /api/v1/openapi?format=yaml — raw YAML source
All paths are versioned under /api/v1. Contract version: v1 — additive changes only; breaking changes get a new version. Every response uses one envelope — success: { "data": … }, error: { "error": { "code", "message" } } — and error messages must be safe to show an end user: no env-var names, vendor names, stack traces, or file paths.
API reference
Every operation in the contract, grouped by surface. The service-contract group is the uniform v1 plane each first-party service implements (health, readiness, manifest, data, search, pages); the per-service groups are their unique surfaces. Each row shows the production server, auth scheme, and a copy-ready curl with placeholder credentials.
The uniform /api/v1 plane every first-party microservice implements (health, ready, manifest, version, summary, activity, search, facts, pages, actions). Documented once; `x-service` lists the implementers and the path-level `servers` array carries each base URL. Dictate does not implement this plane yet.
Authentication
Every credential plane in the platform, rendered from the spec's securitySchemes. Each operation in the explorer above names the scheme(s) it accepts.
| Scheme | Credential | How it works |
|---|---|---|
| blobApiKey | X-Blob-Api-Key | AUTH-1: Blob's static transport key. Fail-closed (503 in prod when unset). |
| guardianApiKey | X-Guardian-Api-Key | AUTH-1: Guardian's static transport key. Fail-closed. |
| decideApiKey | X-Decide-Api-Key | AUTH-1: Decide's static transport key (workspace plane + internal cron). |
| dictateApiKey | x-dictate-api-key | AUTH-1: Dictate's static transport key. Fail-closed. |
| workspaceToken | X-Workspace-Token | AUTH-4 identity JWT minted by the workspace: HS256, iss `workspace.xray.tech`, aud = the target service id (`blob`, `guardian`, `decide`), subject email (+ `companyId` where the service is org-scoped), exp−iat ≤ 300s. Services verify with a constant-time signature check, reject `alg:none`/algorithm confusion, and never trust a forgeable `?email=` in its place. |
| eventSignature | X-XR-Signature | AUTH-2: v1=<hex HMAC-SHA256 of `<X-XR-Timestamp>.<raw body>`> with the producer's own signing key; timestamp within ±300s. |
| contextSignature | X-XR-Signature | AUTH-6: v1=<hex HMAC-SHA256 of `<X-XR-Timestamp>.<raw query string>`> with the service's own context key; the service names itself in X-Workspace-Service-Id. |
| cronSecret | X-Cron-Secret | AUTH-5: shared secret for scheduled internal jobs. |
| decideCallerKey | Authorization: Bearer dcd_… | Decide-issued org-scoped caller API key (hashed at rest). |
| guardianPublicKey | Authorization: Bearer xrg_live_… | Guardian public consumer key, scoped to the key owner's org. The whole plane is OFF unless GUARDIAN_PUBLIC_API_ENABLED=true (404 when off). |
| guardianAdminEmail | x-guardian-admin-email | Guardian admin allowlist (GUARDIAN_ADMIN_EMAILS) for consumer-key administration. |
| dictateUserEmail | X-Dictate-User-Email | Dictate's subject scoping — the acting user's email (header, `?email=` or JSON body). Dictate has no AUTH-4 JWT yet; combine with `dictateApiKey`. |
| forlocoInternalAuth | internalauthorization | ForLoCo's internal shared key (HOURLY_INTERNAL_KEY twin), constant-time compared; the plane is feature-gated (404 when FEATURE_WORKSPACE_EMBED is off, 503 when the key is unset). |
Verifying the identity token (AUTH-4)
The workspace signs an HS256 token with a key you share; you only ever verify. Reject any alg that isn't HS256, check iss = workspace.xray.tech, aud = your service id, and exp; then scope the session to sub (the email). The tier claim is advisory — never authorize on it alone.
const result = verifyWorkspaceToken({
token, // from the X-Workspace-Token header (S2S); embeds will
// deliver the same token as ?wt= — strip it from the URL
// immediately (exchange for an httpOnly cookie)
secrets: [process.env.MYSVC_EMBED_KEY],
audience: "my-service", // YOUR service id
});
if (!result.ok) return unauthorized(result.reason);
const { sub: email, companyId } = result.claims; // scope everything to theseA copy-pasteable, dependency-free verifier (and the full walkthrough) ships in the integration guide — workspace admins can hand it to you from Operations → API Docs.
Emitting events (two-way integration)
When state changes in your product — a ticket resolved, a document indexed — deliver a signed event to the workspace. The workspace verifies the signature against YOUR service's key, validates the envelope against its event catalog, dedupes on the event id, and acknowledges. Emit <yourservice>.notification.createdand the workspace files it into the user's unified notification feed and fans it out to their opted-in channels (bell, batched email, Slack, web push) — broader fact fan-out to other products is still on the roadmap. Your event type must be registered in the workspace catalog before the first delivery — an unregistered type is a permanent 400. Persist events in an outbox in the same transaction as the state change, then deliver with retries until acknowledged.
POST https://workspace.xray.tech/api/webhooks/events
X-XR-Signature: v1=<hex hmac of "timestamp.body">
X-XR-Timestamp: <unix seconds>
X-XR-Event-Id: <ulid> X-XR-Delivery: <uuid per attempt>
{ "id": "<ulid>", "type": "myservice.thing.happened", "service": "myservice",
"occurredAt": "2026-07-11T12:00:00.000Z",
"subject": { "email": "user@example.com" },
"data": { … }, "contractVersion": "v1" }- 2xx = delivered (duplicates return 200 and are safe) — stop retrying.
- 4xx = permanent (bad signature, unknown type) — never retry; alert your ops.
- 429/5xx = transient — retry on a backoff schedule (60s → 12h).
- You can only emit types the workspace catalog assigns to your service — ask an admin to register each type (the catalog, not the type's name prefix, decides ownership).
Error codes
| Code | HTTP | Meaning |
|---|---|---|
| VALIDATION_ERROR | 400 | Body or query failed validation |
| INVALID_JSON | 400 | Request body isn't valid JSON |
| UNKNOWN_EVENT_TYPE | 400 | Event type isn't in the workspace catalog |
| UNAUTHORIZED | 401 | Missing/invalid credential |
| FORBIDDEN | 403 | Authenticated but not entitled |
| NOT_FOUND | 404 | Missing or not owned — never reveal which |
| RATE_LIMITED | 429 | Throttled; retry with backoff |
| INTERNAL_ERROR | 500 | Unexpected; message stays user-safe |
| NOT_CONFIGURED | 501 | Service env not set — degrade, don't crash (permanent, never retried) |
| UPSTREAM_ERROR | 502 | A dependency is down |
Server-driven pages — your UI is an API response
Your service ships no frontend. Beyond data endpoints, a service can describe whole pages as structured blocks — headings, text, stats, lists, tables, cards, forms, and buttons (with tabs and charts as reviewed extensions) — and the workspace renders them natively with its own components and design tokens. No iframe, no third-party JavaScript or CSS; styling is semantic tokens only, so dark mode and accessibility come for free. Three endpoints:
GET /api/v1/pages?email= → { "data": { "pages": [{ "id", "title", "icon", "order" }] } }
GET /api/v1/pages/{id}?email= → { "data": { "page": <PageDocument> } }
POST /api/v1/actions/{actionId} → { "data": { "result": "ok", "refresh": true } }A worked example — a to-do service backed by a task API. This one document renders as a complete page: a headline stat row, an add-task form, and tabbed active/completed lists whose buttons round-trip to your actions endpoint:
{ "blockVersion": "v1", "title": "Today", "blocks": [
{ "type": "heading", "text": "Today", "level": 2 },
{ "type": "text", "md": "You have **4** open tasks.", "emphasis": "muted" },
{ "type": "statGroup", "stats": [
{ "label": "Open", "value": "4", "icon": "circle", "emphasis": "strong" },
{ "label": "Due soon", "value": "2", "icon": "clock", "trend": "up" },
{ "label": "Completed", "value": "4", "icon": "check-circle", "trend": "up" } ] },
{ "type": "form",
"fields": [ { "kind": "text", "id": "title", "label": "New task", "required": true } ],
"submit": { "id": "create", "label": "Add task", "style": "primary", "input": ["title"] } },
{ "type": "tabs", "tabs": [
{ "id": "active", "title": "Active (4)", "blocks": [
{ "type": "list", "items": [
{ "title": "Provision the signing key",
"badge": { "text": "High", "tone": "critical" },
"actions": [ { "id": "complete:t1", "label": "Complete", "style": "primary" } ] } ] } ] },
{ "id": "completed", "title": "Completed (4)", "blocks": [ /* … */ ] } ] }
] }The full runnable reference — this to-do service, its block schema, and the renderer — ships in the workspace repo under examples/sdui-todo/. Rules that keep pages certifiable: page endpoints are email-scoped like every resource, documents validate against the block schema, unknown block types are skipped (the vocabulary can grow without breaking you), every action honors an Idempotency-Key, and deep links are workspace-relative.
Create your developer account & first API key
Your workspace account is your developer account — there is no separate signup. Registering a service starts it in Draft: you get real credentials to build and test against, while nothing is called or trusted by the workspace until the service passes review and goes live.
- Create a workspace account. Sign up with your email (the free tier is enough) and sign in.
- Register your service. In the workspace, open Settings → Integrations → Developer and register a service id (lowercase letters/digits, e.g.
invoiceradar), a display name, and your https base URL. - Generate your credentials. Click Generate keys — you receive your API key (AUTH-1, sent to your service on every workspace call), signing key (AUTH-2/3, for the events you emit), and embed key (AUTH-4, to verify identity tokens). They are shown once — store them in your secret manager immediately.
- Test your build. Point the keys at your service and verify the contract end-to-end: your
/api/v1/healthanswers, a request with the wrong API key gets a 401, and your token verifier rejects a tamperedX-Workspace-Token. The conformance checklist in the quickstart below is exactly what review runs. - Go live. Draft services stay sandboxed. When you pass conformance, ask for review — promotion to live (and any embed or extra identity grants) is a reviewed, trust-tiered decision.
Self-serve registration is rolling out per environment; if the Developer panel isn't visible in Settings yet, a workspace admin can provision the same credentials for you (Operations → API Docs).
Quickstart
- Pick a service id (lowercase letters/digits only, e.g.
invoiceradar— event types must match^[a-z0-9]+(\.[a-z0-9_]+)+$, no hyphens) — it becomes your route and the prefix of your event types. - Implement
/api/v1/health,/ready, and/manifest. - Serve your resources email-scoped under
/api/v1/…with the response envelope; accept and honor anIdempotency-Keyon mutations (make replays safe — the workspace will send it as its write paths adopt the platform proxy). - Add the workspace-token verifier and scope every session to the verified email.
- Generate credentials from your developer account (see above) — or ask a workspace admin, who can also hand you the full normative spec from Operations → API Docs.
- Pass the conformance checklist: bad key → 401, cross-customer reads impossible, idempotent mutations, user-safe error messages, graceful degradation.
Decide — ask a human from any service or agent
Decide is the platform's human-in-the-loop primitive: your service or agent posts a decision — a question with 2–5 options plus context (markdown, facts, links, per-option consequences) — routed by decision role (e.g. finance-approver) to the org members who hold it. You learn the outcome by polling (with an optional long-poll) or via a signed webhook. Keys are issued by an org admin in the workspace (Decide → Roles & API keys); every Decide operation is in the canonical OpenAPI document above (the decide-caller and decide-workspace groups), with the narrative guide in docs/microservices/decide.md.
1 — Create a decision
curl -X POST "$DECIDE_API_URL/api/v1/decisions" \
-H "Authorization: Bearer $DECIDE_CALLER_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"title": "Approve Q3 vendor renewal",
"roleSlug": "finance-approver",
"body": "Usage grew 34% QoQ. Two renewal offers, both lock pricing for 12 months.",
"facts": [{ "label": "Current spend", "value": "$1,140/yr" }],
"links": [{ "label": "Usage dashboard", "url": "https://example.com/usage" }],
"options": [
{ "id": "same", "label": "Renew same tier",
"consequence": "Quota exhausted in ~2 months", "default": true },
{ "id": "upgrade", "label": "Upgrade a tier",
"consequence": "Adds $630/yr to the tools budget" }
],
"urgency": "urgent",
"expiresAt": "2026-08-01T00:00:00Z"
}'
# → 201 { "data": { "decision": { "id": "01J…", "status": "pending", … } } }
# Unknown roleSlug → 400 unknown_role with error.details.validRoles2 — Wait for the human (long-poll, agent-friendly)
// TypeScript: block up to 60s per request until a human decides
async function awaitDecision(id: string): Promise<Decision> {
for (;;) {
const res = await fetch(
`${DECIDE_API_URL}/api/v1/decisions/${id}?wait=60`,
{ headers: { Authorization: `Bearer ${DECIDE_CALLER_KEY}` } },
);
const { data } = await res.json();
const decision = data.decision;
if (decision.status === "needs_info") {
// A decider asked a question — answer it to return to "pending".
const ask = decision.infoRequests.at(-1);
await fetch(`${DECIDE_API_URL}/api/v1/decisions/${id}/info-response`, {
method: "POST",
headers: {
Authorization: `Bearer ${DECIDE_CALLER_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({ infoRequestId: ask.id, answer: answerFor(ask) }),
});
continue;
}
if (decision.status !== "pending") return decision; // resolved | expired | cancelled
}
}
// resolution.kind: "chosen" (+ optionId, rationale) | "declined"
// | "expired_default" (your default applied) | "expired"3 — Or register a signed webhook
POST /api/v1/webhooks { "url": "https://your-service.example/decide-hook" }
# → { "data": { "webhook": { "id": … }, "secret": "…" } } (secret shown once)
# Deliveries on needs_info / resolved / expired / cancelled are signed:
# X-Decide-Signature: v1=<hex HMAC-SHA256 over "<X-Decide-Timestamp>.<rawBody>">
# Verify with your secret and reject skew over 300s — same scheme as the event bus.An MCP tool surface (request_decision, get_decision, list_pending, respond_to_info_request) is part of the contract, so agent frameworks can present decisions as native tools.
Where this is going
Self-serve registration and credential issuance are here (above); next on the roadmap are automated conformance certification, org-level installs with scoped grants, and the server-driven page renderer that draws your block documents in the shell. The direction is documented in the platform RFC and execution plan (admins: Operations → API Docs).