Skip to main content

Airavata HTTP API

Every HTTP route Airavata serves, with its credential, request body, response and errors. Neither route family is stable; Compatibility names the clients that must follow a change.

To findRead
Who may call a route, and with whatRoute families; the session routes and authentication; the batch authentication and authorization
How to sign a user inSign-in
The order of calls for a session or a batch runSessions; Process launch example
What to do with a refusal, and what is safe to retryIdempotency and retries; Session API errors; Batch API errors
Which routes return secrets, and what is left unprotectedRoutes (token routes), attach, access; Known gaps

Route families​

Two binaries serve two route families with disjoint paths: the session API runs interactive sessions on a user's own cluster account; the batch API holds a catalogue of clusters, applications and data, and launches batch processes.

AspectSession APIBatch API
Binarycs serveairavata-server
Listens on--listen, default 127.0.0.1:8045, behind a TLS proxy at --public-url:$SERVER_PORT, default :9095, all interfaces, plain HTTP
CredentialCILogon ID token (JWT), verified locally against the JWKSCILogon access token, verified by RFC 7662 introspection; or the root token
Principalsub, under tenant cilogonusername, else preferred_username, else sub; http://cilogon.org/.../<n> becomes cilogon:<n>
Anonymous request401, except sign-in and token routesAllowed; each route decides
Error body{"error": {"code", "message"}}{"status", "error", "message", "fieldErrors"?}
Unknown JSON fields400 invalid_jsonIgnored

The session API serves the paths oauth, hosts, keys/ssh, devtunnels, sessions and runs. The batch API serves users, groups, ssh-keys, slurm-clusters, slurm-cluster-configs, application-templates, slurm-deployments, scp-data-storages, data-products and processes, and /health.

Neither server forwards to the other or links a session principal to a batch principal; a deployment serving both routes by path in its proxy. A client of both families obtains its batch access token from CILogon separately, since the session sign-in routes return no access token.

ServerCheckConsequence
SessionVerifies the ID token's signature locallyNo CILogon call per request; a token is good until its exp and cannot be revoked
BatchAsks CILogon about each opaque access tokenA token CILogon no longer reports active is refused at once; a CILogon outage answers 502

Session API conventions​

Paths are relative to --public-url. The session examples on this page use:

API=https://api.example.edu
TOKEN='<ID token from the sign-in routes>'

Routes​

Each route accepts only the credential shown; any other path or method is 404 or 405.

RouteMethodsCredential
/api/v1/oauth/configGETNone; Origin required
/api/v1/oauth/exchangePOSTNone; Origin required
/api/v1/oauth/refreshPOSTNone
/api/v1/oauth/devicePOSTNone
/api/v1/oauth/device/pollPOSTNone
/api/v1/keys/sshGET, POSTBearer
/api/v1/keys/ssh/{id}DELETEBearer
/api/v1/hostsGET, POSTBearer
/api/v1/hosts/{alias}PUT, DELETEBearer
/api/v1/hosts/{alias}/healthGETBearer
/api/v1/hosts/{alias}/slurmGETBearer
/api/v1/hosts/{alias}/ssh (WebSocket)GETBearer subprotocol
/api/v1/devtunnelsGET, DELETEBearer
/api/v1/devtunnels/authorizationsPOSTBearer
/api/v1/devtunnels/authorizations/{handle}/pollPOSTBearer
/api/v1/sessionsGET, POSTBearer
/api/v1/sessions/validatePOSTBearer
/api/v1/sessions/{id}GET, DELETEBearer
/api/v1/sessions/{id}/startPOSTBearer
/api/v1/sessions/{id}/attachPOSTBearer
/api/v1/sessions/{id}/stopPOSTBearer
/api/v1/sessions/{id}/accessGETBearer
/api/v1/sessions/{id}/sshPOSTBearer
/api/v1/sessions/{id}/usageGETBearer
/api/v1/runsGETBearer
/api/v1/sessions/{id}/forward/{port} (WebSocket)GETJupyter token subprotocol
/api/v1/sessions/{id}/jupyter/{path}anyJupyter token
/api/v1/sessions/{id}/link (WebSocket, Linkspan)GETLink token subprotocol

The last three are token routes: they check only the Origin and their own per-run token, because their callers (Jupyter clients, Linkspan in a job) hold no bearer. A per-run token opens one run and stops working when it ends.

Authentication​

Every route except the sign-in routes and the token routes requires exactly one header:

Authorization: Bearer <OIDC ID token>

The ID token is validated against the issuer's discovery document and JWKS:

CheckRule
FormatThree-part JWS; alg RS256; kid required; typ empty or JWT
KeyRSA, at least 2048 bits, odd exponent at least 3, from the JWKS (use sig or empty; alg RS256 or empty)
issEquals the discovery document's issuer
audEquals the client ID; must be a JSON string
exp, nbfexp required and in the future; nbf honoured
sub^[A-Za-z0-9._:@/,-]{1,256}$

Discovery is cached for five minutes. An unknown kid forces at most one JWKS refresh every 30 seconds. Any failed check is 401 unauthorized with WWW-Authenticate: Bearer; the client then calls oauth/refresh, and signs in again if that is refused. Every record is scoped to the token's sub; there is no administrator role.

The SSH authentication WebSocket carries the credential as exactly two subprotocols, in any order, and negotiates cybershuttle.v1:

cybershuttle.v1
bearer.<unpadded base64url of the ID token>

Any other protocol set is 400 invalid_websocket_auth; a missing, oversized or non-canonical token is 401. No other bearer route accepts subprotocol authentication.

Origins​

--allowed-origin lists exact HTTPS or loopback HTTP origins.

RequestAnswer
Origin outside the list, any route403 origin_not_allowed
No Origin (a native client)Passes; the bearer or token authenticates it
No Origin on oauth/config or oauth/exchange403 origin_required
Allowed Origin, not a token routeAccess-Control-Allow-Origin, Vary: Origin, Access-Control-Expose-Headers: ETag, Location
Access-Control-Request-Method and Origin on an OPTIONS preflight, not a token route204 with the path's methods
Preflight requesting a header beyond Authorization, Content-Type, If-None-Match (only Content-Type on sign-in routes)403 preflight_not_allowed
Preflight for an unknown path, or a method the path lacks404 not_found, 405 method_not_allowed

Requests and responses​

RuleAnswer
Created SSH host, SSH key or session201 with Location
Successful DELETE204, no body
Unknown path404 not_found
Method the path lacks405 method_not_allowed with a sorted Allow
Request bodyJSON, at most 64 KiB (1 KiB for attach); unknown fields or trailing data are 400 invalid_json
JSON responseCache-Control: no-store

Idempotency and retries​

Only POST /api/v1/sessions takes an idempotency key. After a lost response:

RequestRepeated
Any GET except the WebSocketsSafe; none changes stored state
POST /sessionsThe same idempotencyKey and fields answer the existing session with 200; different fields are 409 idempotency_conflict
POST /sessions/validateSafe; records no session
POST /sessions/{id}/start, attachNot repeatable: each takes a new seq. While a run is live a second call is 409 session_running; read the session before retrying
POST /sessions/{id}/stopSafe; a terminal session is answered unchanged
POST /sessions/{id}/sshSafe; one server per public key
POST /hosts, POST /keys/ssh409 ssh_host_exists, 409 ssh_key_exists; the first call took effect
DELETE of an SSH host or session404; the first call took effect
DELETE /keys/ssh/{id}500 internal_error (see Known gaps); the first call took effect
DELETE /devtunnelsSafe

Sign-in​

These routes complete the CILogon grant and add the OIDC client secret, which only the session server holds: the authorization-code flow with PKCE for browsers, the device grant for other clients. Upstream failures are 502 upstream_unavailable or 502 upstream_invalid.

The browser flow:

A native client uses the device grant:

curl -s -X POST "$API/api/v1/oauth/device"
# open verificationUriComplete, then poll every intervalSeconds:
curl -s -X POST "$API/api/v1/oauth/device/poll" \
-H 'Content-Type: application/json' -d '{"deviceCode":"<deviceCode>"}'

GET /api/v1/oauth/config → 200​

{
"issuer": "https://cilogon.org",
"authorizationEndpoint": "https://cilogon.org/authorize",
"clientId": "cilogon:/client_id/...",
"scope": "openid email profile offline_access"
}

The browser sends the user to authorizationEndpoint with response_type=code, client_id, scope, a redirect_uri on an allowed origin, a state and an S256 code_challenge. An unreachable discovery document is 502 upstream_unavailable.

POST /api/v1/oauth/exchange → 200​

Request
{ "code": "...", "codeVerifier": "...", "redirectUri": "https://jupyter.example.edu/lab/index.html" }
Response
{ "idToken": "...", "refreshToken": "...", "expiresInSeconds": 900 }

refreshToken is present when the issuer granted offline_access; expiresInSeconds is between 1 and 86400. A missing field, a redirectUri off the allowed origins, or a rejected code is 400 invalid_grant.

POST /api/v1/oauth/refresh → 200​

{ "refreshToken": "..." }

Answers exchange's shape, with a rotated refreshToken when the issuer rotates it. An empty token is invalid_json; a refused one is 400 invalid_grant.

POST /api/v1/oauth/device → 200​

No body. Answers deviceCode, userCode, verificationUriComplete and intervalSeconds (at least 5). The device code is safe with the client, since redeeming it needs the client secret. An issuer failure is 502 upstream_unavailable.

POST /api/v1/oauth/device/poll → 200​

{ "deviceCode": "..." }
OutcomeAnswer
Not yet approved (including the issuer's slow_down){ "status": "pending", "intervalSeconds": 5 }
Approved{ "status": "complete", "idToken": "...", "refreshToken": "...", "expiresInSeconds": 900 }

A missing deviceCode is invalid_json; a denied or expired grant is 400 invalid_grant.

SSH keys​

SSH keys are per caller and never returned; see key storage. A passphrase-protected key is accepted.

jq -n --rawfile key ~/.ssh/id_ed25519 '{id: "delta-key", privateKey: $key}' |
curl -s -X POST "$API/api/v1/keys/ssh" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' --data-binary @-

GET /api/v1/keys/ssh → 200​

{ "keys": [{ "id": "delta-key", "type": "ssh-ed25519", "fingerprint": "SHA256:..." }] }

POST /api/v1/keys/ssh → 201​

{ "id": "delta-key", "privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\n..." }
FieldRuleError
id^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$, not ending in .pub; newinvalid_ssh_key_id, 409 ssh_key_exists
privateKeyA private keyinvalid_ssh_key

Answers the key's metadata with Location: /api/v1/keys/ssh/<id>.

DELETE /api/v1/keys/ssh/{id} → 204​

Deletes the key and unassigns it from every SSH host. An unknown ID answers 500 internal_error, not 404 ssh_key_not_found; see Known gaps.

SSH hosts​

SSH hosts are per caller; two callers may use the same alias. See Airavata SSH hosts and keys for parsing and rendering.

curl -s -X POST "$API/api/v1/hosts" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"alias":"delta","command":"ssh -J bastion alice@login.delta.example.edu","keyId":"delta-key"}'

GET /api/v1/hosts → 200​

{
"hosts": [
{
"alias": "delta",
"hostname": "login.delta.example.edu",
"user": "alice",
"port": 22,
"keyId": "delta-key",
"extraDirectives": ["ProxyJump bastion"],
"managed": true
}
]
}

port defaults to 22; user and keyId are omitted when unset; managed is always true. An SSH host with keyId authenticates with that key only (IdentitiesOnly yes); the key's path is never returned.

POST /api/v1/hosts → 201​

{ "alias": "delta", "command": "ssh -J bastion alice@login.delta.example.edu", "keyId": "delta-key" }
FieldRuleError
alias^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$; unique per caller, ignoring caseinvalid_ssh_alias, 409 ssh_host_exists
commandAn ssh command line; see host parsinginvalid_ssh_command
keyIdOptional; a stored key404 ssh_key_not_found

Answers the SSH host with Location: /api/v1/hosts/<alias>.

PUT /api/v1/hosts/{alias} → 200​

{ "command": "ssh -p 2222 -J bastion alice@login2.delta.example.edu", "keyId": "delta-key" }

Replaces the SSH host under the same alias, parsed as for POST; an absent keyId unassigns the key. An unknown alias is 404 ssh_host_not_found.

DELETE /api/v1/hosts/{alias} → 204​

An unknown alias is 404 ssh_host_not_found.

GET /api/v1/hosts/{alias}/health → 200​

{ "alias": "delta", "ok": true, "message": "Listening at login.delta.example.edu:22." }

Opens a TCP connection to the first hop without authenticating; see host parsing. A closed port or a non-public address answers 200 with ok: false and Nothing accepted a connection at <address>. An unknown alias is 404 ssh_host_not_found.

GET /api/v1/hosts/{alias}/slurm → 200​

{
"alias": "delta",
"accounts": ["project-a"],
"partitions": [
{ "name": "gpuA100", "cpuCount": 64, "memoryMb": 243200, "gres": [{ "name": "gpu:a100", "count": 4 }] }
],
"homeDir": "/home/alice"
}

The remote user's Slurm accounts, sinfo partitions and home directory. A partition appears once per node configuration, so a name can repeat; a session must fit one entry. A GRES entry without a count is left out.

RefusalCode
Unknown alias404 ssh_host_not_found
Non-interactive SSH authentication fails409 ssh_authentication_required
Discovery script fails502 slurm_discovery_failed

GET /api/v1/hosts/{alias}/ssh (WebSocket)​

Interactive SSH authentication, establishing the control master. A client opens it after 409 ssh_authentication_required and repeats its request after ready, which comes at once when a healthy master exists.

RefusalCode
No Upgrade header426 upgrade_required
Another authentication in flight for the SSH host409 ssh_authentication_in_progress
Service stopping503 service_stopping
DirectionBinary framesText frames
Airavata → clientPTY output, including password and second-factor promptsready, then exit with code 0; on failure, exit with a nonzero code and a message
Client → AiravataKeystrokes, at most 32 KiB eachresize with columns and rows

The text frames in full:

{"type":"ready"}
{"type":"exit","code":0}
{"type":"exit","code":<n>,"message":"..."}
{"type":"resize","cols":100,"rows":30}

On failure code is ssh's exit status when it has one, and message is one of a fixed set: failed to prepare SSH, failed to start SSH, SSH authentication failed, SSH control master exited before becoming ready, SSH authentication service is stopping. Remote diagnostics are never sent. Resize honours cols 20–500 and rows 5–200. Frames are capped at 64 KiB; the session server pings every 20 seconds; the PTY starts at 100×30.

Dev Tunnels account​

An optional Microsoft or GitHub account, with which the session server makes a Dev Tunnel for each run using the devtunnel transport. The credential is sealed under the caller's principal and never returned. See Dev Tunnels.

HANDLE=$(curl -s -X POST "$API/api/v1/devtunnels/authorizations" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"provider":"github"}' | jq -r .handle)
# enter userCode at verificationUri, then poll every intervalSeconds:
curl -s -X POST "$API/api/v1/devtunnels/authorizations/$HANDLE/poll" -H "Authorization: Bearer $TOKEN"

GET /api/v1/devtunnels → 200​

{ "connected": true, "provider": "github", "account": "octocat", "connectedAt": "2026-09-17T10:00:00Z" }

{ "connected": false } when none is connected. provider is microsoft or github; account is the Microsoft username or GitHub login when known.

DELETE /api/v1/devtunnels → 204​

Disconnects the account and forgets its credential; idempotent.

POST /api/v1/devtunnels/authorizations → 200​

Request
{ "provider": "github" }
Response
{
"handle": "<43-character opaque handle>",
"userCode": "ABCD-EFGH",
"verificationUri": "https://github.com/login/device",
"expiresInSeconds": 900,
"intervalSeconds": 5
}

Starts a device-code authorization with the provider's Dev Tunnels client. handle is the session server's reference; the device code never reaches the client.

RefusalCode
Other provider400 unknown_provider
More than one start per second per caller429 rate_limited
256 authorizations already pending503 broker_capacity
Provider unreachable, refused, malformed502 upstream_unavailable, upstream_failure, upstream_invalid

POST /api/v1/devtunnels/authorizations/{handle}/poll → 200​

OutcomeAnswer
Pending{ "status": "pending", "intervalSeconds": 5, "connected": false }
Connected{ "status": "connected", "connected": true, "provider": "microsoft", "account": "someone@outlook.com", "connectedAt": "..." }

A provider slow_down lengthens intervalSeconds, up to 60.

RefusalCode
Unknown handle, or another caller's404 not_found
Poll sooner than intervalSeconds429 rate_limited
Denied403 authorization_denied
Expired410 authorization_expired
Provider unreachable, refused, malformed502 upstream_unavailable, upstream_failure, upstream_invalid

The handle is discarded on any terminal outcome.

Sessions​

On every bearer route below, an unknown {id} is 404 session_not_found and another principal's is 403 session_owner_mismatch. States, transitions and identifiers are in Airavata sessions and runs.

StepRoute
Check a requestPOST /api/v1/sessions/validate
Define a sessionPOST /api/v1/sessions
Start itPOST /api/v1/sessions/{id}/start, or attach for a job the client submits
Reach itGET /api/v1/sessions/{id}/access, then the Jupyter proxy or a forward; POST /api/v1/sessions/{id}/ssh for an SSH server
End the runPOST /api/v1/sessions/{id}/stop
Delete itDELETE /api/v1/sessions/{id}

Defining touches no cluster; each start is a new run of the same definition. Only access and attach return a session's secrets.

BODY='{"idempotencyKey":"5f2b0d0a-3f14-4a9c-9a1e-6c2b0f7d51ab","alias":"delta","partition":"cpu",
"rootFolder":"$HOME/project","resources":{"cores":2,"memoryMb":4096,"wallMinutes":60}}'
H=(-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json')
curl -s -X POST "$API/api/v1/sessions/validate" "${H[@]}" -d "$BODY"
ID=$(curl -s -X POST "$API/api/v1/sessions" "${H[@]}" -d "$BODY" | jq -r .id)
curl -s -X POST "$API/api/v1/sessions/$ID/start" "${H[@]}"
curl -s "$API/api/v1/sessions/$ID/access" "${H[@]}" # once state is READY
curl -s -X POST "$API/api/v1/sessions/$ID/stop" "${H[@]}"

Session record​

GET /api/v1/sessions/{id} → 200 answers the record; POST /api/v1/sessions, start and stop answer it too.

{
"id": "s-012345abcdef",
"seq": 1,
"state": "READY",
"platform": "jupyterlab",
"alias": "delta",
"account": "project-a",
"partition": "cpu",
"rootFolder": "$HOME/project",
"resources": { "cores": 2, "memoryMb": 4096, "wallMinutes": 60 },
"tunnelModes": ["link"],
"createdAt": "2030-01-01T00:00:00Z",
"startedAt": "2030-01-01T00:00:30Z",
"updatedAt": "2030-01-01T00:01:00Z"
}
FieldMeaning
ids- and 12 hex characters, derived from the caller and the idempotencyKey
seq0 before the first run, then the current run; start and attach increment it
stateSUBMITTING, QUEUED, STARTING, READY, STOPPING, STOPPED or FAILED
platformLauncher of the latest run: jupyterlab (start, and the default) or vscode (attach)
tunnelModesThe run's transports, sorted: link, devtunnel or both
startedAtFirst Slurm RUNNING or link, whichever is earlier; with wallMinutes, the deadline

account, error and startedAt are omitted when empty. READY means the run is reachable; see transitions.

POST /api/v1/sessions/validate → 200​

{
"idempotencyKey": "5f2b0d0a-3f14-4a9c-9a1e-6c2b0f7d51ab",
"alias": "delta",
"account": "project-a",
"partition": "cpu",
"rootFolder": "$HOME/project",
"resources": { "cores": 2, "memoryMb": 4096, "wallMinutes": 60, "gpuType": "a100", "gpuCount": 1 },
"tunnelModes": ["link", "devtunnel"]
}
FieldRuleError
idempotencyKeyRequired, at most 128 bytes, no NUL, CR or LFinvalid_idempotency_key
alias^[A-Za-z0-9][A-Za-z0-9_.-]*$, at most 128 characters; a configured SSH hostinvalid_ssh_alias; 404 ssh_host_not_found at discovery
accountOptional; same pattern, at most 64 characters; a discovered accountinvalid_account
partitionSame pattern, at most 64 characters; a discovered partitioninvalid_partition
cores2 to 4096invalid_resources
memoryMb4096 to 100000000invalid_resources
wallMinutes1 to 525600invalid_resources
gpuType, gpuCountBoth or neither, gpuCount at least 1; available on the partitioninvalid_gpu
All resourcesFit one discovered partition entry; CPU-only requests match only partitions without GPU GRESinvalid_resource
rootFolderSee root folderinvalid_root_folder
tunnelModesOptional, default ["link"]; distinct values from link and devtunnelinvalid_tunnel_modes
devtunnel in tunnelModesA connected Dev Tunnels account409 devtunnels_account_required
Response
{
"sessionId": "s-012345abcdef",
"script": "#!/bin/bash\n#SBATCH --nodes=1\n...",
"status": "PASSED",
"message": "Slurm accepted the job script."
}

Runs sbatch --test-only on the job script start would submit, differing only in its log path. A Slurm rejection is 200 with status: "FAILED"; a failed SSH call is an error (409 ssh_authentication_required, 502 slurm_discovery_failed). stdout and stderr carry sbatch's output and are omitted when empty.

POST /api/v1/sessions → 201 or 200​

Takes validate's body, checked for shape only, and records a STOPPED session at seq 0 without touching the SSH host or checking the Dev Tunnels account. A new session is 201 with Location; a replay with the same fields is 200; the same key with different fields is 409 idempotency_conflict. Answers the session record.

GET /api/v1/sessions → 200 or 304​

{
"sessions": [],
"logs": [
{
"sessionId": "s-012345abcdef",
"lines": [
{ "stream": "status", "text": "Preparing the session environment", "at": "2030-01-01T00:00:05Z" },
{ "stream": "stdout", "text": "Installing collected packages", "at": "2030-01-01T00:00:07Z" }
]
}
]
}

The caller's session records and log tails; it never waits on SSH. stream is status (the session server's narration), stdout or stderr (the job's startup output); at is when the session server first saw the line. Bounds, redaction and the move to run history are in logs.

The response carries a strong ETag (SHA-256 of the body). If-None-Match accepts *, lists and weak validators; a match is 304 with no body.

POST /api/v1/sessions/{id}/start → 200​

Validates as validate does, then starts the next run through Slurm with new per-run tokens and, for devtunnel, a new Dev Tunnel; see the start sequence. Answers the session record with platform: "jupyterlab", normally QUEUED. A conclusive submission failure leaves the session FAILED at the new seq.

RefusalCode
Session not terminal, or another start is launching it409 session_running
Slurm rejects the script400 slurm_validation_failed
devtunnel without a connected Dev Tunnels account409 devtunnels_account_required
Another start is preparing the same SSH host for this caller409 session_provisioning_in_progress
SSH host preparation fails502 or 504 session_provisioning_failed
SSH authentication fails non-interactively409 ssh_authentication_required
Dev Tunnels credential refresh fails502 upstream_unavailable
Service stopping503 service_stopping

POST /api/v1/sessions/{id}/attach → 200​

{
"session": { "id": "s-012345abcdef", "seq": 2, "state": "QUEUED", "platform": "vscode", "...": "..." },
"port": 31337,
"link": { "url": "wss://api.example.edu/api/v1/sessions/s-012345abcdef/link", "token": "<43-character token>" },
"devtunnel": { "id": "s-012345abcdef-2", "cluster": "usw3", "hostToken": "<host token>" }
}

Admits a job the client, such as CS Bridge, submits itself. The optional body { "tunnelModes": [...] } (at most 1 KiB) is validated as for validate and replaces the session's transports; without it they are kept.

attach freezes the previous run, takes the next seq and new per-run tokens, creates a Dev Tunnel only for devtunnel, and persists the session QUEUED with platform: "vscode". link is present only with the link transport and devtunnel only with devtunnel; no other route returns the host token.

The client then runs Linkspan with:

TransportFlagsEnvironment
Any--port <port> --tunnel-enable --tunnel-mode <transports>
link--tunnel-link-args "--url <url>"LINKSPAN_LINK_TOKEN, the link token
devtunnel--tunnel-devtunnel-args "--id <id> --cluster <cluster>"LINKSPAN_TUNNEL_HOST_TOKEN, the host token

The link, or Linkspan answering through the Dev Tunnel, moves the session to READY. Refusals are 409 session_running, 409 devtunnels_account_required and 502 upstream_unavailable, as for start. A client that cancels the job without calling stop leaves the run READY.

GET /api/v1/sessions/{id}/access → 200​

{
"sessionId": "s-012345abcdef",
"seq": 1,
"expiresAt": "2030-01-01T01:00:00Z",
"jupyter": {
"uri": "https://api.example.edu/api/v1/sessions/s-012345abcdef/jupyter/",
"token": "<43-character token>"
}
}

With attach, the only session route that returns a secret. token is Jupyter Server's token and the credential every forward offers. expiresAt is startedAt + wallMinutes, or now plus the walltime without startedAt. A session that is not READY, has no stored tokens, or has neither a link nor a Dev Tunnel is 409 session_access_unavailable, with the reason in the message.

/api/v1/sessions/{id}/jupyter/{path}​

Proxies any method, WebSockets included, to {path} on the session's Jupyter Server. The Jupyter token goes in Authorization: token <token> or a token query parameter; the header wins when both are present. Jupyter Server answers a CORS preflight without a token, but the session must still be READY.

RefusalCode
Foreign Origin403 origin_not_allowed
Wrong token, or session not READY401
Jupyter Server unreachable502 upstream_unavailable

GET /api/v1/sessions/{id}/forward/{port} → 101​

A WebSocket carrying one TCP connection, as binary frames, to a port Linkspan serves in the session: an SSH server, Jupyter Server, the control port, or any other task's port. It takes no bearer. The client offers exactly cybershuttle.v1 and capability.<token>, in either order, with token from /access; that token suffices because it already grants code execution as the user in that run.

RefusalCode
Foreign Origin403 origin_not_allowed
Other subprotocols, wrong token, unknown session, not READY401, checked before the port
Malformed or zero port404 not_found
Port unreachable502 upstream_unavailable

POST /api/v1/sessions/{id}/ssh → 200​

Request
{ "publicKey": "ssh-ed25519 ..." }
Response
{ "port": 2222 }

Starts an SSH server in the owner's READY session that authorizes publicKey, and answers the port to reach through forward/{port}. Idempotent per key: each key's server is named ssh-<16 hex of sha256(key)>.

RefusalCode
Linkspan refuses the key400 invalid_ssh_key
Session unreachable409 session_access_unavailable
Other Linkspan failure502 upstream_failure

GET /api/v1/sessions/{id}/usage → 200​

{
"sessionId": "s-012345abcdef",
"samples": [
{
"at": "2030-01-01T00:05:00Z",
"memBytes": 2147483648,
"cpuUsageUsec": 295339339,
"gpus": [{ "index": 0, "utilPct": 40, "memUsedMiB": 1024, "memTotalMiB": 40960 }]
}
]
}

Up to the last 20 samples, taken from Linkspan every five seconds while the session is READY; null when there are none. Every figure is optional: an unreadable counter is absent, not zero. at is when the session server took the sample, so consecutive cpuUsageUsec values give a rate.

POST /api/v1/sessions/{id}/stop → 200​

Marks the session STOPPING, drops its link, releases its Dev Tunnel and per-run tokens, and asks Slurm to cancel the job. A vscode run is marked STOPPED without Slurm; its client cancels the job. Stopping a terminal session answers it unchanged.

DELETE /api/v1/sessions/{id} → 204​

Deletes a terminal session's record and per-run tokens; its runs stay in run history. A session that is not terminal is 409 session_not_stopped.

The WebSocket a session's Linkspan dials and holds; no other client calls it. Linkspan offers exactly cybershuttle.v1 and link.<token>, in either order, with the per-run link token from its environment or from attach. Any other offer, an unknown session or a terminal session is 401. A link for the current run moves a QUEUED or STARTING session to READY. See the link protocol.

Run history​

GET /api/v1/runs → 200 answers the caller's finished runs, newest first, one per (sessionId, seq).

curl -s "$API/api/v1/runs" -H "Authorization: Bearer $TOKEN"
{
"runs": [
{
"sessionId": "s-012345abcdef",
"seq": 1,
"platform": "jupyterlab",
"alias": "delta",
"partition": "cpu",
"rootFolder": "$HOME/project",
"resources": { "cores": 2, "memoryMb": 4096, "wallMinutes": 60 },
"tunnelModes": ["link"],
"finalState": "STOPPED",
"startedAt": "2030-01-01T00:00:30Z",
"endedAt": "2030-01-01T01:00:30Z",
"stats": {
"cores": 2,
"requestedMemory": "4.0 GB",
"elapsedSeconds": 3600,
"maxRss": "2.0 GB",
"cpuEfficiencyPct": 50,
"memoryEfficiencyPct": 50
},
"logs": [{ "stream": "status", "text": "Session is running", "at": "2030-01-01T00:00:05Z" }]
}
]
}

A run is frozen with its log tail and last usage samples when it ends. It survives the next start and the deletion of its session; the newest 200 runs across all callers are kept. stats is Slurm accounting, fetched only for jupyterlab runs and absent until it arrives. platform, account, error, startedAt, stats, samples and logs are omitted when empty.

Session API errors​

Every refusal is one envelope:

{ "error": { "code": "session_not_found", "message": "session not found" } }

A client branches on code; message is for people. An unclassified failure is 500 internal_error; its detail is neither returned nor logged.

StatusCodes
400invalid_json, invalid_websocket_auth, invalid_ssh_alias, invalid_ssh_command, invalid_ssh_key_id, invalid_ssh_key, invalid_root_folder, invalid_partition, invalid_account, invalid_gpu, invalid_resource, invalid_resources, invalid_idempotency_key, invalid_session_id, invalid_tunnel_modes, slurm_validation_failed, invalid_grant, unknown_provider
401unauthorized
403session_owner_mismatch, origin_required, origin_not_allowed, preflight_not_allowed, authorization_denied
404not_found, session_not_found, ssh_host_not_found, ssh_key_not_found
405method_not_allowed
409session_running, session_not_stopped, idempotency_conflict, session_provisioning_in_progress, session_access_unavailable, ssh_host_exists, ssh_key_exists, ssh_authentication_required, ssh_authentication_in_progress, devtunnels_account_required
410authorization_expired
426upgrade_required
429rate_limited
500internal_error
502session_provisioning_failed (504 on timeout), slurm_discovery_failed, upstream_unavailable, upstream_invalid, upstream_failure
503service_stopping, broker_capacity

Refusals that are not the client's mistake:

CodeClient action
401 unauthorizedRefresh the ID token; sign in again if refused
409 ssh_authentication_requiredOpen the SSH authentication WebSocket, then repeat the request
409 session_provisioning_in_progress, 503 service_stopping, 503 broker_capacityRepeat later
429 rate_limitedWait intervalSeconds, or a second after a refused start
502, 504The cluster, CILogon or the Dev Tunnels provider failed. Repeat a read; after start, read the session first, since a run may have been claimed

Batch API conventions​

Paths are relative to the airavata-server address, http://localhost:9095 by default. GET /health answers {"status":"UP"} without a token.

BATCH=http://localhost:9095
TOKEN='<CILogon access token, or the root token printed at startup>'

Authentication​

Authorization: Bearer <token> is optional; the scheme is matched without regard to case. A request without a token is anonymous and each route decides. A Bearer token that is present but invalid is always 401. A header with another scheme is ignored, and the request is anonymous.

TokenResult
AbsentAnonymous; a route that needs a caller answers 401
The root tokenPrincipal root with SUPER_ADMIN; compared in constant time before any network call
Introspection says active: truePrincipal from the claims; authorities from user_roles, or USER when it has no rows
Introspection answers non-200 or active: false401 with WWW-Authenticate: Bearer error="invalid_token"
Introspection unreachable or malformed502, message Unable to validate bearer token

Authorities never come from the token. No route writes user_roles; to make an administrator, insert a row in the database. The root token is enabled by default; disable it in production.

Every self-service create (group, SSH key, cluster config, SCP data storage, data product, process) needs a users row whose userId is the principal name; without one the create is 404, naming the principal. A super admin creates the row with POST /api/v1/users; the root token's row is created at startup.

Authorization​

RuleWho passesRefusal
OpenAnyone, token or not
AuthenticatedAny principal401
AdminADMIN or SUPER_ADMIN401 anonymous, 403 otherwise
Super adminSUPER_ADMINas above
OwnerThe record's owner only; admins do not pass403
Owner or adminThe owner, or an admin403
READ, WRITEThe owner, an admin, or a user share or ACTIVE group-membership share granting at least that permission; WRITE implies READ403

Requests and responses​

No batch route takes an idempotency key: a client that lost a response lists before repeating. A second launch of a process is 409.

RuleAnswer
Request bodyJSON; unknown fields ignored; malformed JSON is 400 Malformed JSON request body
Created record201 with the record, no Location; POST /api/v1/users answers 200
Successful DELETE204, no body
IDsServer-generated UUIDs, except userId
TimestampsEpoch milliseconds (createdAt, timestamp)
OPTIONS204, before authentication

An allowed Origin is echoed with Access-Control-Allow-Credentials: true, Access-Control-Allow-Headers: Authorization, Content-Type and Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS; with the default *, every origin is allowed.

Users​

RouteAuthorizationAnswer
GET /api/v1/usersAdmin200, array
POST /api/v1/usersSuper admin200, the user
GET /api/v1/users/{userId}The user or an admin200
POST /api/v1/users/{userId}The user or a super admin200; updates email, firstName, lastName only
FieldRule
userIdRequired, not blank; the principal name, such as cilogon:12345
emailOptional; must contain @ with text on both sides and no space or tab
firstName, lastNameRequired, not blank
authMethodOptional, CILOGON or SYSTEM; not validated

A user answers userId, status (ACTIVE on registration), firstName, lastName and createdAt; email is not returned.

Groups​

RouteAuthorizationAnswer
GET /api/v1/groupsAdmin200, every group
GET /api/v1/groups/meAuthenticated200, groups the caller owns or belongs to, suspended memberships included
POST /api/v1/groupsAuthenticated, with a users row201; the creator becomes an ACTIVE ADMIN member
GET /api/v1/groups/{groupId}Reader200
PUT /api/v1/groups/{groupId}Group owner200, renamed
DELETE /api/v1/groups/{groupId}Group owner204; memberships go with it
GET /api/v1/groups/{groupId}/membersReader200
POST /api/v1/groups/{groupId}/membersManager201; 409 if already a member
GET /api/v1/groups/{groupId}/members/{userId}Reader200
PUT /api/v1/groups/{groupId}/members/{userId}Manager200; omitted fields unchanged
DELETE /api/v1/groups/{groupId}/members/{userId}Manager, or the member themselves204; 409 for the owner's membership
StandingHolders
ReaderThe owner, any ACTIVE member, admins. Anyone else gets 404, not 403
ManagerThe owner, ACTIVE members with group role ADMIN or MODERATOR, admins
Group ownerThe owner and admins

Bodies: a group takes groupName (required). A member takes userId (required, a registered user), groupRole (ADMIN, MODERATOR or MEMBER, default MEMBER) and groupMemberStatus (ACTIVE or INACTIVE, default ACTIVE); PUT takes the last two. Only the owner or an admin may change the owner's membership.

Batch SSH keys​

A key belongs to whoever registered it; it has no shares, and admins are not treated as owners.

RouteAuthorizationAnswer
GET /api/v1/ssh-keysAuthenticated200, the caller's keys
POST /api/v1/ssh-keysAuthenticated, with a users row201
GET /api/v1/ssh-keys/{sshKeyId}Owner200
PUT /api/v1/ssh-keys/{sshKeyId}Owner200; an omitted privateKey keeps the stored one
DELETE /api/v1/ssh-keys/{sshKeyId}Owner204; 409 while a cluster config or SCP data storage presents it
FieldRule
sshKeyName, publicKeyRequired, not blank
privateKeyRequired on create; write-only
passphraseOptional; write-only

A key answers sshKeyId, sshKeyName, publicKey and ownerId. Putting a key on a cluster config or SCP data storage requires owning it (403 otherwise), unless the record already holds that key.

Slurm clusters​

A cluster describes a machine; how a person logs in to it is a cluster config.

RouteAuthorizationAnswer
GET /api/v1/slurm-clustersOpen200, clusters with partitions inline
POST /api/v1/slurm-clustersAdmin201; inline partitions are written in the same transaction
GET /api/v1/slurm-clusters/{slurmClusterId}Open200
PUT /api/v1/slurm-clusters/{slurmClusterId}Admin200; 400 with non-empty partitions in the body
DELETE /api/v1/slurm-clusters/{slurmClusterId}Admin204, partitions included; 409 while a cluster config uses it
GET /api/v1/slurm-clusters/{slurmClusterId}/partitionsOpen200
POST /api/v1/slurm-clusters/{slurmClusterId}/partitionsAdmin201
GET /api/v1/slurm-clusters/{slurmClusterId}/partitions/{partitionId}Open200; 404 for another cluster's partition
PUT /api/v1/slurm-clusters/{slurmClusterId}/partitions/{partitionId}Admin200
DELETE /api/v1/slurm-clusters/{slurmClusterId}/partitions/{partitionId}Admin204
Cluster fieldRule
clusterName, headnodeHostRequired, not blank
headnodePortRequired, 1–65535
clusterDescription, slurmHome, dataHostOptional
dataPortOptional, 1–65535
partitionsOptional, create only

A partition takes name (required) and the optional description, maxRunTime, maxNodes, maxProcessors, maxJobsInQueue, maxMemory, cpuPerNode, defaultNodeCount, defaultCpuCount, defaultWalltime, gres, nodes, isDefaultQueue and isCheckpointable. Responses add slurmClusterId, and partitionId and clusterId per partition.

Slurm cluster configs​

A config is one way of logging in to a cluster: an account, an SSH key and a work root. It belongs to whoever registered it; others reach it through sharing.

RouteAuthorizationAnswer
GET /api/v1/slurm-cluster-configsAdmin200, every config
GET /api/v1/slurm-cluster-configs/meAuthenticated200, the caller's
GET /api/v1/slurm-cluster-configs/shared-with-meAuthenticated200, shared with the caller
POST /api/v1/slurm-cluster-configsAuthenticated, with a users row201
GET /api/v1/slurm-cluster-configs/{slurmClusterConfigId}READ200
PUT /api/v1/slurm-cluster-configs/{slurmClusterConfigId}WRITE200; owner unchanged
DELETE /api/v1/slurm-cluster-configs/{slurmClusterConfigId}Owner or admin204; shares go with it
FieldRule
slurmClusterIdRequired; an existing cluster (404 otherwise)
loginUser, workRootRequired, not blank
sshKeyIdRequired; a key the caller owns (404 unknown, 403 another's)
name, descriptionOptional

A config answers its fields plus slurmClusterConfigId, ownerId, the cluster inline as slurmCluster, the key's public summary as sshKey, and permission, what the caller may do with it (absent from the admin and /me lists). A process may submit under a config with READ.

Application templates​

A template declares an application's inputs and outputs, independent of where it runs.

RouteAuthorizationAnswer
GET /api/v1/application-templatesOpen200
POST /api/v1/application-templatesAdmin201
GET /api/v1/application-templates/{templateId}Open200
PUT /api/v1/application-templates/{templateId}Admin200; inputs and outputs recreated with new IDs
DELETE /api/v1/application-templates/{templateId}Admin204; 409 while a deployment uses it
FieldRule
templateNameRequired, not blank
templateDescriptionOptional
inputs[].inputNameRequired, unique within the template
inputs[].inputTypeRequired: STRING, INTEGER, FLOAT, BOOLEAN, FILE, FILE_LIST or DIRECTORY
inputs[].requiredBoolean
inputs[].displayName, inputDescription, defaultValueOptional; defaultValue is a string holding JSON, such as "{\"value\":\"monomer\"}"
outputs[].outputNameRequired
outputs[].outputTypeRequired: FILE, FILE_LIST or DIRECTORY
outputs[].displayName, outputDescriptionOptional

Responses add templateId, and inputId or outputId per declaration; process mappings name those IDs.

Slurm deployments​

A deployment binds a template to a cluster with a run script section and default resources.

RouteAuthorizationAnswer
GET /api/v1/slurm-deploymentsOpen200; ?templateId= filters
POST /api/v1/slurm-deploymentsAdmin201
GET /api/v1/slurm-deployments/{deploymentId}Open200
PUT /api/v1/slurm-deployments/{deploymentId}Admin200
DELETE /api/v1/slurm-deployments/{deploymentId}Admin204
FieldRule
templateIdRequired; an existing template (404 otherwise)
slurmClusterIdOptional; when present, an existing cluster (404 otherwise)
slurmRunSectionRequired, not blank
defaultBatchJobConfigRequired: wallTimeMinutes (positive) and allocation (not blank) required; cpus, mem, memPerCpu, ntasksPerNode, cpusPerTask, nodes, ntasks, gres, gpus, memPerGpu, cpusPerGpu, gpusPerNode, constraints optional
defaultPartitionOptional, a partition name

Responses add deploymentId and defaultBatchJobConfig.batchJobConfigId.

SCP data storages​

A storage is a host, login account and SSH key that datasets are staged through. It belongs to whoever registered it; others reach it through sharing.

RouteAuthorizationAnswer
GET /api/v1/scp-data-storagesAdmin200, every storage
GET /api/v1/scp-data-storages/meAuthenticated200
GET /api/v1/scp-data-storages/shared-with-meAuthenticated200
POST /api/v1/scp-data-storagesAuthenticated, with a users row201
GET /api/v1/scp-data-storages/{dataStorageId}READ200
PUT /api/v1/scp-data-storages/{dataStorageId}WRITE200
DELETE /api/v1/scp-data-storages/{dataStorageId}Owner or admin204; 409 while data products are registered on it
FieldRule
dataName, hostName, loginUserRequired, not blank
portOptional, 1–65535, default 22; an update without it resets it to 22
sshKeyIdRequired; a key the caller owns

Responses add dataId, ownerId, the key's public summary as sshKey, and permission (absent from the admin and /me lists).

Data products​

A data product is a registered path on an SCP data storage. It belongs to whoever registered it; others reach it through sharing.

RouteAuthorizationAnswer
GET /api/v1/data-productsAdmin200, every product
GET /api/v1/data-products/meAuthenticated200
GET /api/v1/data-products/shared-with-meAuthenticated200
POST /api/v1/data-productsAuthenticated, with a users row, and READ on the storage201
GET /api/v1/data-products/{dataProductId}READ200
PUT /api/v1/data-products/{dataProductId}WRITE, and READ on the storage named200
DELETE /api/v1/data-products/{dataProductId}Owner or admin204; shares go with it
FieldRule
dataName, pathRequired, not blank
isFileRequired; false for a directory
dataStorageIdRequired; unknown is 404, unreachable is 403
dataStorageTypeOptional, SCP (the default) or HPC
dataDescriptionOptional

Responses add dataId, provisionStatus (REGISTERD on create, spelled as in code), ownerId, createdAt and permission (absent from the admin and /me lists). Registering a product does not touch the host.

Sharing​

Cluster configs, SCP data storages and data products share one pattern. {base} is /api/v1/slurm-cluster-configs/{slurmClusterConfigId}, /api/v1/scp-data-storages/{dataStorageId} or /api/v1/data-products/{dataProductId}. Every sharing route, reads included, is owner or admin.

RouteAnswer
GET {base}/user-shares, GET {base}/group-shares200, array
POST {base}/user-shares with userId, POST {base}/group-shares with groupId201
PUT {base}/user-shares/{sharingId}, PUT {base}/group-shares/{sharingId}200; body permission, required
DELETE {base}/user-shares/{sharingId}, DELETE {base}/group-shares/{sharingId}204

permission is READ or WRITE, default READ on create. userId or groupId must exist (404). Sharing twice with the same user or group, or with the owner, is 409. A sharingId of another record is 404. A group share reaches only ACTIVE members. The response's ID field is named for its kind, such as slurmClusterConfigUserSharingId, dataStorageGroupSharingId or dataProductUserSharingId.

curl -s -X POST "$BATCH/api/v1/slurm-cluster-configs/$CONFIG/group-shares" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"groupId":"'"$GROUP"'","permission":"READ"}'

Processes​

A process is one run. A BATCH_JOB process carries its deployment, cluster config, resources and input and output values in a batchProcess section; there is no separate batch-process route.

Process routes​

RouteAuthorizationAnswer
GET /api/v1/processesAdmin200, every process
GET /api/v1/processes?deploymentId=Open200, that deployment's runs
POST /api/v1/processesAuthenticated, with a users row, and READ on the cluster config201; first status CREATED in the same transaction
GET /api/v1/processes/{processId}Open200
PUT /api/v1/processes/{processId}Admin200, mappings replaced; 409 on a changed processType
DELETE /api/v1/processes/{processId}Admin204; batch section, statuses, mappings and tasks go with it
POST /api/v1/processes/{processId}/launchOwner or admin202, the process; no body
GET /api/v1/processes/{processId}/statusesOpen200, oldest first
GET /api/v1/processes/{processId}/statuses/{statusId}Open200; 404 for another process's status

Process body​

FieldRule
processTypeRequired: BATCH_JOB or CLOUD_JOB
batchProcessRequired for BATCH_JOB, refused otherwise; holds the fields below
.deploymentIdRequired
.slurmClusterConfigIdRequired; 404 unknown, 403 without READ
.batchJobConfigRequired; the shape of a deployment's defaultBatchJobConfig
.baseWorkDirOptional; the run works in <baseWorkDir>/<processId>, or under the config's workRoot when absent
.jobIdOptional
.inputMappings[]templateInputId (required) and value
.outputMappings[]templateOutputId (required) and value

A mapping value is a string. For a STRING, INTEGER, FLOAT or BOOLEAN declaration it holds JSON, {"value": "..."} or {"values": [...]}; for a FILE, FILE_LIST or DIRECTORY declaration it is a data product's dataId.

A process answers processId, userId, processType, lastStatusId and batchProcess. The batch section echoes the request with batchProcessId, the batchJobConfigId and each mapping's ID, and adds batchJobStatuses and latestBatchJobStatus: Slurm states from job e-mail (SUBMITTED, BEGIN, END, FAIL, TIME_LIMIT, …) with updatedAt in epoch milliseconds.

A status answers processStatusId, processId, status (CREATED, SUBMITTED, RUNNING, COMPLETED or FAILED), log and timestamp; statuses are never accepted from a client.

Launch tasks​

Launch refuses a process that already has staging or submission tasks with 409. For a BATCH_JOB it then records these tasks and starts the submission workflow:

TaskCounttaskOrderonFailure, retryCount
Stage in: product storage → <workingDir>/<inputName>One per FILE input0RETRY, 3
Job submission, with workingDirOne1EXIT, 1
Job monitoringOne3RETRY, 10
Stage out: <workingDir>/<outputName> → product storageOne per FILE output4RETRY, 3

A stage-in task's destination is type HPC with the cluster config's ID. FILE_LIST and DIRECTORY mappings get no staging task. A deployment without a template or cluster, or a batch section without a deployment, is 409.

Process tasks​

Four task collections sit under a process, each with the same five routes. Every route is owner or admin; processId comes from the path only.

CollectionOwn fields
data-staging-taskssourcePath, destinationPath (required); sourceDataStorageId, destinationDataStorageId; sourceDataStorageType, destinationDataStorageType (SCP or HPC)
job-submission-tasksjobId, workingDir
job-monitoring-tasksjobId
interactive-command-taskscommand (required), output
RouteAnswer
GET /api/v1/processes/{processId}/{collection}200, by taskOrder, unordered tasks last
POST /api/v1/processes/{processId}/{collection}201
GET /api/v1/processes/{processId}/{collection}/{taskId}200; 404 for another process's task
PUT /api/v1/processes/{processId}/{collection}/{taskId}200
DELETE /api/v1/processes/{processId}/{collection}/{taskId}204

Every kind also takes onFailure (RETRY, SKIP or EXIT), retryCount and taskOrder (both not negative), and answers taskId and processId.

Process launch example​

This example uses the root token, which is SUPER_ADMIN and has a users row, so one token registers the catalogue and owns the run. It assumes the key's public half is installed for alice on the cluster.

api() { curl -s -X "$1" "$BATCH$2" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' ${3:+-d "$3"}; }

KEY=$(api POST /api/v1/ssh-keys "$(jq -nc --rawfile k ~/.ssh/id_ed25519 --rawfile p ~/.ssh/id_ed25519.pub \
'{sshKeyName: "expanse-key", privateKey: $k, publicKey: $p}')" | jq -r .sshKeyId)

CLUSTER=$(api POST /api/v1/slurm-clusters '{"clusterName":"expanse","headnodeHost":"login.expanse.sdsc.edu",
"headnodePort":22,"partitions":[{"name":"compute"}]}' | jq -r .slurmClusterId)

CONFIG=$(api POST /api/v1/slurm-cluster-configs "$(jq -nc --arg c "$CLUSTER" --arg k "$KEY" \
'{slurmClusterId: $c, loginUser: "alice", workRoot: "/expanse/lustre/scratch/alice", sshKeyId: $k}')" \
| jq -r .slurmClusterConfigId)

TEMPLATE=$(api POST /api/v1/application-templates '{"templateName":"word-count",
"inputs":[{"inputName":"text","inputType":"FILE","required":true}],
"outputs":[{"outputName":"counts","outputType":"FILE"}]}')

DEPLOYMENT=$(api POST /api/v1/slurm-deployments "$(jq -nc --arg t "$(jq -r .templateId <<<"$TEMPLATE")" --arg c "$CLUSTER" \
'{templateId: $t, slurmClusterId: $c, slurmRunSection: "wc -w text > counts",
defaultBatchJobConfig: {wallTimeMinutes: 10, allocation: "abc123"}, defaultPartition: "compute"}')" \
| jq -r .deploymentId)

STORAGE=$(api POST /api/v1/scp-data-storages "$(jq -nc --arg k "$KEY" \
'{dataName: "expanse-home", hostName: "login.expanse.sdsc.edu", loginUser: "alice", sshKeyId: $k}')" | jq -r .dataId)

product() { api POST /api/v1/data-products "$(jq -nc --arg s "$STORAGE" --arg n "$1" --arg p "$2" \
'{dataName: $n, isFile: true, path: $p, dataStorageId: $s}')" | jq -r .dataId; }
IN=$(product input /home/alice/input.txt)
OUT=$(product output /home/alice/counts.txt)

PROCESS=$(api POST /api/v1/processes "$(jq -nc --argjson t "$TEMPLATE" --arg d "$DEPLOYMENT" --arg c "$CONFIG" \
--arg in "$IN" --arg out "$OUT" \
'{processType: "BATCH_JOB", batchProcess: {deploymentId: $d, slurmClusterConfigId: $c,
batchJobConfig: {wallTimeMinutes: 10, allocation: "abc123"},
inputMappings: [{templateInputId: $t.inputs[0].inputId, value: $in}],
outputMappings: [{templateOutputId: $t.outputs[0].outputId, value: $out}]}}')" | jq -r .processId)

api POST "/api/v1/processes/$PROCESS/launch" # 202
api GET "/api/v1/processes/$PROCESS/statuses"
api GET "/api/v1/processes/$PROCESS/job-submission-tasks"
Statuses after create
[
{
"processStatusId": "9f8e7d6c-5b4a-4392-8172-6a5b4c3d2e1f",
"processId": "a1b2c3d4-e5f6-4708-9a1b-2c3d4e5f6a7b",
"status": "CREATED",
"log": null,
"timestamp": 1790000000000
}
]

Batch API errors​

Every JSON refusal is one envelope:

{
"status": 400,
"error": "Bad Request",
"message": "Validation failed",
"fieldErrors": [{ "field": "clusterName", "message": "Cluster name cannot be blank" }]
}

fieldErrors appears only on validation failures and names JSON paths such as batchProcess.inputMappings[0].templateInputId. A client branches on status and shows message.

StatusWhen
400Malformed JSON, or a body that fails its constraints
401No token where a route needs a caller, a token introspection rejects, or an active token with no user name
403Authenticated, but lacking the authority, ownership or share
404No such record, no users row for the caller, or a group the caller has no standing in
405A method the path lacks, such as POST on statuses
409Collides with existing state: a referenced record, a duplicate share or membership, a second launch
500Any unclassified failure, as Internal server error; the detail is logged
502Token introspection could not be completed

Unknown paths (404) and missing methods (405) are answered by Go's router as plain text, not the JSON envelope.

Known gaps​

AreaBehaviour
Anonymous readsGET /api/v1/processes/{processId}, ?deploymentId= listings and both status routes need no token, so any caller holding a process or deployment ID can read the run, including its config ID and work directory
Launch errorsAn unknown or unreadable data product, a cluster config the owner can no longer read, no work directory, a missing processType, or a BATCH_JOB without a batch section answers 500, not 404, 403 or 409
Launch atomicityTasks are saved one by one outside a transaction. A launch that fails part-way keeps the tasks already saved, and a retry is then 409
Launch workflowA failure to start the submission workflow is not reported; the response is still 202
StagingFILE_LIST and DIRECTORY mappings are not staged
Deletes behind foreign keysThese deletes fail on a RESTRICT foreign key and answer 500, not 409: a cluster config or deployment a process references, a cluster a deployment references, a group holding any share
Template updatesPUT recreates inputs and outputs with new IDs, and cascades away every process mapping that named the old ones
Task storage typeA bad sourceDataStorageType is reported as must be one of SCP, S3; the accepted values are SCP and HPC
RolesADMIN and SUPER_ADMIN come only from user_roles rows, which no route writes
Key storagessh_keys.private_key and passphrase are stored unencrypted; see Persistence
Session key deleteDELETE /api/v1/keys/ssh/{id} with an unknown ID answers 500 internal_error, not 404 ssh_key_not_found