What a service client is
- Owned by the project. The project keeps the client when the person or agent who created it leaves. Its requests are billed to the project as agent requests, against the same included requests.
- Read only or Manage. Set at creation, like a signed-in agent. Read only lists and reads; Manage also changes, publishes and spends. See Read only and Manage.
- Capabilities. Any of
screens,content,playlists,advertising,reportsandproject, within the creator's own. - Bounded. A service client never creates or manages service clients, members, invitations or agent sign-ins.
- Two ways to sign in. A registered public key (
private_key_jwt, the recommended way) or a secret (client_secret_basic). A client holds up to two of each, so you can rotate without downtime.
A project holds up to 50 live service clients. Client ids start with scl_ and secrets with sr_cs_.
Create a key
Generate an Ed25519 key pair on the machine that runs the client. The private key never leaves it; screenRIG keeps only the public half.
openssl genpkey -algorithm ed25519 -out screenrig-client.pem
chmod 600 screenrig-client.pemEC P-256 keys and RSA keys of at least 2048 bits work too.
Create the client
In the dashboard. Open Access and choose New service client. Name it, choose Read only or Manage, check its capabilities, and paste the public key as a JWK, or choose Generate a client secret. Print the public JWK of your key with Node:
node -e 'const c=require("node:crypto");const k=c.createPublicKey(require("node:fs").readFileSync("screenrig-client.pem"));console.log(JSON.stringify(k.export({format:"jwk"})))'With the CLI. An agent with the project capability and Manage access creates one directly. --key-file takes a JWK or PEM file and sends only its public half:
screenrig service-client create --name "Nightly report" --access read \
--capability reports --key-file screenrig-client.pemTo use a secret instead, add --secret-file PATH. The CLI writes the secret to that new file with mode 0600; it is shown once and never again.
screenrig service-client show scl_ID lists the client's key ids (kid) and secret ids. A key without its own kid is known by its RFC 7638 thumbprint.
Get an access token
Ask the token endpoint for a token with the client_credentials grant. The response carries access_token, token_type: Bearer, expires_in (900 seconds) and scope. There is no refresh token: ask again when it expires.
With a secret, authenticate with HTTP Basic. A secret in the request body is refused:
curl -s https://api.screenrig.ai/oauth/token \
-u "$SCREENRIG_CLIENT_ID:$SCREENRIG_CLIENT_SECRET" \
-d grant_type=client_credentialsWith a key, send a signed client assertion instead of a secret:
curl -s https://api.screenrig.ai/oauth/token \
-d grant_type=client_credentials \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
-d client_assertion="$ASSERTION"The assertion is a JWT signed with your private key:
| Field | Value |
|---|---|
Header alg | EdDSA, ES256 or RS256, matching the key |
Header kid | The registered key's id |
iss and sub | The client id |
aud | https://api.screenrig.ai or https://api.screenrig.ai/oauth/token, as one string |
iat, exp | At most 5 minutes apart |
jti | A new random value for every assertion; a repeat is refused |
Headers jku, x5u, jwk and x5c are refused.
Ask for less
Two optional parameters narrow a token:
scope:access:readmakes a Manage client's token Read only, and capability names keep only those capabilities, for examplescope=access:read reports. A scope beyond the client's isinvalid_scope.resource:https://api.screenrig.ai/api, the default, for the REST API, orhttps://api.screenrig.ai/mcpfor the remote server.
Call the API
Send the token as a bearer. It acts on the client's own project:
curl -s https://api.screenrig.ai/api/screens \
-H "Authorization: Bearer $TOKEN"Node: a cached token with a key
This helper signs the assertion with Node's built-in crypto, keeps the token in memory, and asks for a new one a minute before it expires. Set CLIENT_KEY_ID to the key's kid from service-client show:
import { createPrivateKey, randomUUID, sign } from "node:crypto";
import { readFileSync } from "node:fs";
const ISSUER = "https://api.screenrig.ai";
const { SCREENRIG_CLIENT_ID: clientId, SCREENRIG_CLIENT_KEY_FILE: keyFile, CLIENT_KEY_ID: kid } = process.env;
const key = createPrivateKey(readFileSync(keyFile));
const part = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
let cached;
function assertion() {
const now = Math.floor(Date.now() / 1000);
const input = `${part({ alg: "EdDSA", typ: "JWT", kid })}.${part({ iss: clientId, sub: clientId, aud: ISSUER, iat: now, exp: now + 60, jti: randomUUID() })}`;
return `${input}.${sign(null, Buffer.from(input), key).toString("base64url")}`;
}
export async function accessToken() {
if (cached && cached.expiresAt - 60_000 > Date.now()) return cached.token;
const response = await fetch(`${ISSUER}/oauth/token`, {
method: "POST",
body: new URLSearchParams({
grant_type: "client_credentials",
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: assertion(),
}),
});
const body = await response.json();
if (!response.ok) throw new Error(`screenRIG token request refused: ${body.error}: ${body.error_description ?? ""}`);
cached = { token: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 };
return cached.token;
}Python: a cached token with a secret
import base64, json, os, time, urllib.parse, urllib.request
ISSUER = "https://api.screenrig.ai"
_cached = {"token": None, "expires_at": 0.0}
def access_token() -> str:
if _cached["token"] and _cached["expires_at"] - 60 > time.time():
return _cached["token"]
pair = f'{os.environ["SCREENRIG_CLIENT_ID"]}:{os.environ["SCREENRIG_CLIENT_SECRET"]}'
request = urllib.request.Request(
f"{ISSUER}/oauth/token",
data=urllib.parse.urlencode({"grant_type": "client_credentials"}).encode(),
headers={"Authorization": "Basic " + base64.b64encode(pair.encode()).decode()},
)
with urllib.request.urlopen(request, timeout=10) as response:
body = json.load(response)
_cached.update(token=body["access_token"], expires_at=time.time() + body["expires_in"])
return body["access_token"]Run the CLI as a service client
Set SCREENRIG_CLIENT_ID with SCREENRIG_CLIENT_KEY_FILE (the private key, recommended) or SCREENRIG_CLIENT_SECRET, and every screenrig command runs as that client with no sign-in step. The CLI keeps the token in memory and never reads or writes the stored config:
export SCREENRIG_CLIENT_ID=scl_ID
export SCREENRIG_CLIENT_KEY_FILE=/run/secrets/screenrig-client.pem
screenrig screen listSet exactly one of the two. Each command asks for its own token, so a job that runs more than a few hundred commands an hour is better served by one token reused through the API.
Rotate and revoke
| To | Dashboard (Access, the client) | CLI |
|---|---|---|
| Add a second key | Add key | screenrig service-client add-key scl_ID --key-file new.pem |
| Remove a key | Remove key | screenrig service-client remove-key scl_ID --kid KID |
| Add a second secret | Generate secret | screenrig service-client add-secret scl_ID --secret-file new-secret |
| Remove a secret | Remove secret | screenrig service-client remove-secret scl_ID --secret-id css_ID |
| Change access or capabilities | Access, then Save access | Use the dashboard |
| End the client | Revoke | screenrig service-client revoke scl_ID --yes |
To rotate without downtime, add the new key or secret, deploy it, then remove the old one. Tokens issued with a removed key or secret stop working within seconds, and a client keeps at least one key or secret.
Narrowing a client's access or capabilities ends its current tokens; its next token carries the new access. Revoking is permanent: the keys and secrets are deleted, the client's tokens stop working within seconds, and the record stays on the Access page for audit. Deleting the project revokes its clients.
A client gets tokens only while its project is active. A cancelled or deleted project issues none. A project at zero credit still issues tokens, and its paid requests answer 402 payment_required, as an agent's do.
Errors and limits
| Response | Cause | Fix |
|---|---|---|
401 invalid_client | Wrong or removed secret, a bad or repeated assertion, an unknown kid, a revoked client, or a cancelled or deleted project | Check the credentials against service-client show; send a fresh jti each time |
400 invalid_scope | A scope beyond the client's access or capabilities | Ask for less, or change the client |
400 invalid_target | A resource other than the two listed | Use the default or the remote server's |
429 rate_limited | More than 600 tokens per client, or 1,200 token requests per address, in an hour | Reuse each token until it expires |
503 temporarily_unavailable | Try again shortly | Retry with backoff |
401 from /api | The token expired or was revoked | Ask for a new token once, then stop |
403 insufficient_access from /api | A Read only token tried a change | Use a Manage client |
402 payment_required from /api | The project has no credit for a paid request | Add credit to the project |