Skip to content

api reference

Intended Documentation

Physical Authority API Reference

Every endpoint, request field, response field, and failure mode for /v1/physical/* — ground truth for direct HTTP integration, matched to the runtime code.

Physical Authority API Reference#

This is the contract for direct HTTP integration against /v1/physical/* — every field below is matched to the runtime (services/api/src/routes/physical.ts). Use it when there is no SDK for your language, or when you need the exact wire format for a verifier.

Base URL and authentication#

https://api.intended.so

Every endpoint requires a verified tenant context. Present a tenant-scoped API key and the tenant header on every call:

Authorization: Bearer mrt_live_…
x-tenant-id:   tenant_acme_prod
Content-Type:  application/json

A request that cannot resolve a verified tenant is rejected with 401 unauthenticated. The API key authenticates the caller; it does not bind a token to a robot. What binds an issued token to a specific robot is the actorIdentity claim — and that actor must be registered on the tenant roster first (see POST /v1/physical/actors).

The /v1 prefix

The gateway strips a leading /v1 before routing, so /v1/physical/classify and /physical/classify reach the same handler. The paths are written with /v1/ here because that is the public, versioned form.

Endpoint summary#

MethodPathPurpose
POST/v1/physical/classifyClassify a structured goal into an OI code (no token).
POST/v1/physical/authority-tokensRun the authority loop; mint a token on ALLOW.
POST/v1/physical/authority-tokens/{jti}/revokeRevoke a previously-issued token.
GET/v1/physical/revocationsPull revocations since a timestamp (verifier sync).
POST/v1/physical/snapshotsRecord a state snapshot, return a ref.
GET/v1/physical/auditList audit entries for the tenant (filterable).
GET/v1/physical/audit/{jti}Fetch the audit entry for one issued token.
GET/v1/physical/approvalsList escalation tickets (operator inbox).
GET/v1/physical/approvals/{ticketId}Poll one escalation ticket.
POST/v1/physical/approvals/{ticketId}/decideOperator approves or rejects a ticket.
POST/v1/physical/actorsRegister / update an actor on the fleet roster.
GET/v1/physical/actorsList registered actors.
POST/v1/physical/policyUpload a tenant policy (optionally activate).
GET/v1/physical/policyRead the active tenant policy.

Error envelope#

All non-2xx responses use a nested envelope:

json
{
  "error": {
    "code": "validation_error",
    "message": "human-readable explanation",
    "details": { "issues": [ "…" ] }
  }
}

details is present only on some errors. Validation failures carry details.issues (the Zod issue list). Policy denials carry details with the deciding clause and safe-default (see below).

HTTPcodeMeaning
400validation_errorRequest body failed schema validation; see details.issues.
401unauthenticatedNo verified tenant context.
403actor_not_registeredclaims.actorIdentity is not on the tenant roster. Register it first.
403forbiddenAn Idempotency-Key belongs to a different tenant.
404not_foundToken, ticket, or audit entry does not exist for this tenant.
409conflictIdempotency-Key reused with a different body, or a ticket is no longer pending.
422policy_deniedPolicy denied, or the classifier failed closed; details.safeDefault is the fallback.
423policy_deniedSame shape as 422, but the deciding clause is an audit-gap clause (chain stale).

DENY is 422, not 403

A policy denial is 422 policy_denied, not 403. 403 on this surface means the caller is not allowed (unregistered actor, cross-tenant idempotency key). The audit-gap clause is the one case that maps to 423 instead of 422 — the body shape is identical, the status is the differentiator.


POST /v1/physical/classify

Classifies a structured goal into an OI category. Does not issue a token. Useful when your pipeline wants to inspect the classification before deciding whether to request authority.

POST/v1/physical/classifyRequires auth
goalStructuredGoal*The structured goal to classify. See the StructuredGoal fields below.

StructuredGoal fields

FieldTypeRequiredNotes
schemastringyesNamespace of the payload, e.g. ros2:action:nav2_msgs/NavigateToPose, opc-ua:1.04:method. Drives which rule matches.
verbstringyesLower-case kebab action, e.g. move-to, pick, release-payload.
objectstring | nullnoWhat the verb acts on, e.g. workpiece-12, waypoint-3.
parametersobjectnoFlat key/value bag of verb parameters (defaults to {}).
actor.kindenumyesrobot | drone | vehicle | cobot | agv | amr | rov | other.
actor.identifierstringyesIdentity of the agent issuing the goal.
sourceTimestampMsintnoEdge-side origin time, for replay-window checks.

Request

json
{
  "goal": {
    "schema": "ros2:action:moveit_msgs/Pick",
    "verb": "pick",
    "object": "workpiece-A4-7",
    "parameters": { "frame_id": "cell_origin" },
    "actor": { "kind": "cobot", "identifier": "cobot-east-3" }
  }
}

Response 200

json
{
  "oiCode": "OI-1502",
  "confidence": 0.97,
  "safetyBit": true,
  "safetyCitations": ["OI-2903", "OI-2904", "OI-2906"],
  "alternatives": [{ "oiCode": "OI-1801", "confidence": 0.86 }],
  "failClosed": false,
  "explanation": "MoveIt / manipulation primitive."
}
FieldTypeNotes
oiCodestringMatches OI-NNN / OI-NNNN.
confidencenumber0–1.
safetyBitbooleanWhen true, safetyCitations cite the relevant OI-29xx safety categories.
safetyCitationsstring[]Each matches OI-29NN.
alternativesarrayUp to three lower-ranked { oiCode, confidence }.
failClosedbooleantrue when no rule matched confidently — treat as a denial.
explanationstring | nullRule rationale.

Classifier coverage is a seed corpus

The structured-goal classifier is a rule-based seed corpus (ROS2 nav2/MoveIt, OPC-UA, PX4/ArduPilot, surgical, AGV/AMR, AV, energy, mining, construction, marine, agriculture). A goal outside the corpus returns oiCode: "OI-1800" (generic actuation), confidence: 0.2, and failClosed: true. This is the only classifier in the platform that emits OI-NNNN codes at runtime; the digital/text path does not.


POST /v1/physical/authority-tokens

Runs the full authority loop and, on ALLOW, mints the token. The endpoint will classify the goal itself if you omit intent.oiCode.

Headers

HeaderRequiredNotes
Idempotency-KeyrecommendedSame key + same body within 24h replays the original response. Same key + different body → 409 conflict. A key from another tenant → 403 forbidden.

Request fields

FieldTypeRequiredNotes
intent.structuredGoalStructuredGoalyesSame shape as /classify.
intent.oiCodestringnoPre-classification (OI-NNN/OI-NNNN). If omitted, the server classifies.
dagNode.nodeIdstringyesDAG node identifier; echoed into the token.
dagNode.oiCodestringyesOI code for the node.
dagNode.deadlineMsint > 0yesThe token's lifetime. expiresAtMs = issuedAt + deadlineMs.
dagNode.safeDefaultenumyesstop | hold-position | request-operator | transition-safe-state | abort-mission | ignore.
dagNode.realTimeTierenumnobest-effort (default) | rt-soft | rt-hard.
dagNode.streamingbooleannotrue requires streamingWindowMs.
claims.actorIdentitystringyesMust be registered (else 403 actor_not_registered).
claims.deadlineMsint > 0yes
claims.safeDefaultstringyes
claims.safetyBitbooleannoDefaults true. Overridden by classifier output when the server classifies.
claims.safetyCitationsstring[]noDefaults [].
physicalStatemapnopredicate → PhysicalStateValue (see wire format). Snapshotted into the audit chain.
operatorTicketIdstring | nullnoSet to an approved ticket id when re-issuing after an ESCALATE.

Response 200 — ALLOW

json
{
  "decision": "ALLOW",
  "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Imt…",
  "oiCode": "OI-1502",
  "expiresAtMs": 1735689600200,
  "safetyBit": true,
  "safetyCitations": ["OI-2903", "OI-2904", "OI-2906"],
  "physicalStateRef": "state_…",
  "tokenJti": "01HQ…"
}

physicalStateRef is null when no physicalState was submitted. The token is the RS256 JWT detailed in Token claims.

Response 200 — ESCALATE

json
{
  "decision": "ESCALATE",
  "escalationTicketId": "esc_…",
  "approvalUrl": "https://app.intended.so/approvals/esc_…",
  "safeDefault": "request-operator",
  "expiresAtMs": 1735689660000
}

expiresAtMs here is the ticket TTL (one hour), not a token TTL. Poll /approvals/{ticketId}; once approved, re-call this endpoint with operatorTicketId set.

Response 422 (or 423) — DENY

json
{
  "error": {
    "code": "policy_denied",
    "message": "deny-on-emergency-stop: safety/emergency_stop == true",
    "details": {
      "decision": "DENY",
      "clauseId": "deny-on-emergency-stop",
      "safeDefault": "stop",
      "safetyBit": true,
      "safetyCitations": ["OI-2901", "OI-2902"]
    }
  }
}

When the classifier fails closed (no oiCode supplied and no rule matched), the same 422 policy_denied envelope is returned with details.clauseId: "classifier-fail-closed" and details.explanation. When the deciding clause is an audit-gap clause, the status is 423.

Failure modes for this endpoint

HTTPcodeCause
400validation_errorBody failed schema validation.
401unauthenticatedNo verified tenant.
403actor_not_registeredclaims.actorIdentity not on the roster.
403forbiddenIdempotency-Key belongs to another tenant.
409conflictIdempotency-Key reused with a different body.
422policy_deniedPolicy denied, or classifier failed closed.
423policy_deniedAudit-gap clause tripped.

The full ALLOW / ESCALATE / DENY branch is illustrated below.

The physical authority loop
A request enters the issuer; it is classified if needed, evaluated against policy over a state snapshot, and routed to one of three outcomes — ALLOW mints a short-lived RS256 token, ESCALATE returns an operator ticket, DENY returns a 422 with the deciding clause and safe-default.

One request, three terminal outcomes. ALLOW mints a token bound to the action; ESCALATE waits on a human; DENY (422, or 423 for audit-gap) carries the clause's safe-default.


POST /v1/physical/authority-tokens/{jti}/revoke

Revokes a previously-issued token. The jti must correspond to an audit entry for this tenant (else 404 not_found).

Request

json
{ "reason": "operator-recall: cell evacuated" }

Response 200

json
{ "tokenJti": "01HQ…", "revokedAtMs": 1735689700000 }

GET /v1/physical/revocations

The endpoint a verifier polls to keep its local revocation list current.

Query

ParamTypeNotes
sinceint ≥ 0Return revocations with revokedAtMs > since (defaults to 0).

Response 200

json
{
  "revocations": [
    { "tokenJti": "01HQ…", "revokedAtMs": 1735689700000, "reason": "operator-recall: cell evacuated" }
  ],
  "asOfMs": 1735689701234
}

Poll with since set to the previous response's asOfMs to fetch only new revocations. The server returns at most 1000 entries per call, oldest first.


POST /v1/physical/snapshots

Records a PhysicalStateProvider snapshot in the audit chain without issuing a token — useful for forensic checkpoints or pre-staging state.

Request

json
{
  "physicalState": {
    "safety/emergency_stop": {
      "kind": "boolean", "value": false, "asOfTimestampMs": 1735689600000,
      "attestation": { "channel": "cell-safety-plc/estop-chain", "safetyRated": true, "protocol": "profisafe" }
    }
  }
}

Response 200

json
{ "physicalStateRef": "state_…", "asOfTimestampMs": 1735689600000 }

GET /v1/physical/audit/{jti}

Returns the audit-chain entry for a previously-issued token (scoped to your tenant).

Response 200

json
{
  "tokenJti": "01HQ…",
  "tenantId": "tenant_acme_prod",
  "issuedAtMs": 1735689600000,
  "expiresAtMs": 1735689600200,
  "actorIdentity": "cobot-east-3",
  "oiCode": "OI-1502",
  "decision": "ALLOW",
  "structuredGoal": { "…": "…" },
  "dagNode": { "…": "…" },
  "physicalStateRef": "state_…",
  "physicalState": { "…": "…" },
  "safetyBit": true,
  "safetyCitations": ["OI-2903", "OI-2904", "OI-2906"],
  "verifications": []
}

verifications is an array of { atMs, edgeNodeId, result } entries appended as edge verifiers report checks. It is empty today — edge-verifier reporting is roadmap.


GET /v1/physical/audit

Lists audit entries for the tenant, most recent first.

Query

ParamTypeNotes
limitint 1–200Defaults to 50.
oiCodestringFilter to one OI code.
actorIdentitystringFilter to one actor.

Response 200: { "entries": [ … ] } — each entry has the shape above.


GET /v1/physical/approvals

Operator inbox — lists escalation tickets, most recent first.

Query

ParamTypeNotes
statusenumpending (default) | approved | rejected | expired.
limitint 1–200Defaults to 50.

Response 200: { "tickets": [ … ] }.

GET /v1/physical/approvals/{ticketId}

Polls one ticket.

json
{
  "ticketId": "esc_…",
  "tenantId": "tenant_acme_prod",
  "status": "pending",
  "approvedBy": null,
  "approvedAtMs": null,
  "rejectionReason": null,
  "expiresAtMs": 1735689660000,
  "intent": { "oiCode": "OI-1502", "structuredGoal": { "…": "…" } },
  "createdAtMs": 1735689600000
}

A pending ticket past expiresAtMs is reported as expired. When status == "approved", re-issue against /authority-tokens with operatorTicketId set.

POST /v1/physical/approvals/{ticketId}/decide

Operator approves or rejects a pending ticket.

Request

FieldTypeRequiredNotes
decisionenumyesapproved | rejected.
operatorstringyesIdentity of the deciding operator.
reasonstringnoRecorded on rejection.

Response 200: the updated ticket. Deciding a ticket that is no longer pending returns 409 conflict; an unknown ticket returns 404 not_found.


POST /v1/physical/actors

Registers (or updates) an actor on the tenant fleet roster. Idempotent on (tenantId, actorIdentity).

Request

FieldTypeRequiredNotes
actorIdentitystringyesThe identity tokens will bind to.
actorKindenumyesrobot | cobot | drone | agv | amr | rov | vehicle | other.
actorIdentityKindstringnoDefaults to ieee-802-1ar-devid.
displayNamestringno
metadataobjectnoFree-form.

Response 200

json
{
  "id": "…",
  "tenantId": "tenant_acme_prod",
  "actorIdentity": "cobot-east-3",
  "actorIdentityKind": "ieee-802-1ar-devid",
  "actorKind": "cobot",
  "displayName": "Cell East — UR10e",
  "status": "active",
  "metadata": null
}

GET /v1/physical/actors

Returns { "actors": [ … ] }, most recently registered first.


POST /v1/physical/policy

Uploads a tenant-specific policy. Until a tenant uploads its own, the runtime uses the default policy (deny on emergency-stop, light-curtain breach, human-in-cell).

Request

FieldTypeRequiredNotes
policyanyyesThe policy document (array of clauses under a clauses key — see PLC migration).
activatebooleannoDefaults false. When true, this version becomes active and any previously-active version is deactivated.

Response 200: { "id": "…", "version": 3, "active": true }. Uploads are versioned per tenant.

GET /v1/physical/policy

Returns the active policy: { "active": { … } | null, "version": 3 | null, "activatedAt": "…" | null }.


Token claims#

The minted token is an RS256 JWT ({ "alg": "RS256", "typ": "JWT", "kid": … }). Standard and physical claims:

ClaimNotes
isshttps://api.intended.so
subactorIdentity
audintended-edge-verifier
iat / expSeconds. exp = iat + ceil(deadlineMs / 1000).
jtiToken id; the audit lookup key.
intended.version2
intended.oiCodeClassified OI code.
intended.tenantIdIssuing tenant.
intended.actorIdentity / actorIdentityKindBound identity; actorIdentityKind is ieee-802-1ar-devid.
intended.deadlineMs / issuedAtMs / expiresAtMsMillisecond timing (operationally useful for sub-second TTLs that round to the same exp).
intended.safeDefault / safetyBit / safetyCitationsCarried from the DAG node and classifier.
intended.dagNodeId / realTimeTierFrom the DAG node.
intended.physicalStateRefRef to the evaluated snapshot, or null.
intended.operatorTicketIdSet when issued after an approved ESCALATE, else null.

JWKS signer and edge verifier are not GA

/.well-known/jwks.json serves the physical signer's public key. In dev/sandbox the signer is an ephemeral per-process keypair that rotates on restart — cache short and refresh on kid miss. A KMS-backed signer path exists (INTENDED_PHYSICAL_KMS_KEY_ID). A dedicated edge verifier binary and a native Rust verifier SDK are roadmap — they are not in the repository. Verify against the cloud-served JWKS today.


Wire format#

PhysicalStateValue

A discriminated union on kind:

ts
type PhysicalStateValue =
  | { kind: "boolean";     value: boolean; asOfTimestampMs: number; attestation: Attestation }
  | { kind: "number";      value: number;  unit?: string; asOfTimestampMs: number; attestation: Attestation }
  | { kind: "string";      value: string;  asOfTimestampMs: number; attestation: Attestation }
  | { kind: "unavailable"; reason: string; asOfTimestampMs?: number };

type Attestation = {
  channel:     string;
  safetyRated: boolean;   // default false
  protocol:    "fsoe" | "profisafe" | "pilz-pss" | "opc-ua-safety"
             | "ros2-quality-of-service" | "trusted-bus" | "untrusted";  // default "untrusted"
};

The unavailable variant carries no attestation; it represents a predicate your provider could not resolve. Policy clauses that require safety-rated input treat unavailable (and untrusted) channels as failing.

safeDefault enum#

"stop" | "hold-position" | "request-operator" |
"transition-safe-state" | "abort-mission" | "ignore"

ignore is for non-safety-critical actions; it should not appear in any clause covering an OI-29xx (Embodied AI Safety) category.

realTimeTier enum#

"best-effort" | "rt-soft" | "rt-hard"

Idempotency#

POST /v1/physical/authority-tokens honors an Idempotency-Key header. The same key with the same body within 24 hours replays the original status and body (including stored DENY/ESCALATE responses). The same key with a different body returns 409 conflict. A key first used by another tenant returns 403 forbidden. Recommended: dagNodeId + a monotonic counter.

See also#

Physical Authority API Reference | Intended