Documentation

Give servers and CI jobs their own access

A service client is a server, script or CI job that works on one project with its own credentials. It signs in with a private key or a secret, receives a 15-minute access token, and never needs a person to approve each run.

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, reports and project, 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.pem

EC 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.pem

To 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_credentials

With 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:

FieldValue
Header algEdDSA, ES256 or RS256, matching the key
Header kidThe registered key's id
iss and subThe client id
audhttps://api.screenrig.ai or https://api.screenrig.ai/oauth/token, as one string
iat, expAt most 5 minutes apart
jtiA 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:read makes a Manage client's token Read only, and capability names keep only those capabilities, for example scope=access:read reports. A scope beyond the client's is invalid_scope.
  • resource: https://api.screenrig.ai/api, the default, for the REST API, or https://api.screenrig.ai/mcp for 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 list

Set 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

ToDashboard (Access, the client)CLI
Add a second keyAdd keyscreenrig service-client add-key scl_ID --key-file new.pem
Remove a keyRemove keyscreenrig service-client remove-key scl_ID --kid KID
Add a second secretGenerate secretscreenrig service-client add-secret scl_ID --secret-file new-secret
Remove a secretRemove secretscreenrig service-client remove-secret scl_ID --secret-id css_ID
Change access or capabilitiesAccess, then Save accessUse the dashboard
End the clientRevokescreenrig 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

ResponseCauseFix
401 invalid_clientWrong or removed secret, a bad or repeated assertion, an unknown kid, a revoked client, or a cancelled or deleted projectCheck the credentials against service-client show; send a fresh jti each time
400 invalid_scopeA scope beyond the client's access or capabilitiesAsk for less, or change the client
400 invalid_targetA resource other than the two listedUse the default or the remote server's
429 rate_limitedMore than 600 tokens per client, or 1,200 token requests per address, in an hourReuse each token until it expires
503 temporarily_unavailableTry again shortlyRetry with backoff
401 from /apiThe token expired or was revokedAsk for a new token once, then stop
403 insufficient_access from /apiA Read only token tried a changeUse a Manage client
402 payment_required from /apiThe project has no credit for a paid requestAdd credit to the project