---
title: Integrating a service consumer
order: '31'
summary: Ship a new server-side ScaiLabs service (or your own third-party service)
  that consumes ScaiClip via SCSG. End-to-end walkthrough.
path: tutorials/integrate-a-service-consumer
status: published
---

Your service — a hosted document editor, a voice assistant, a remote terminal, whatever — needs clipboard access. This walkthrough gets you from "we want to build this" to "user consented, we're operating on their clipboard, we close cleanly."

## What you need before you start

- A registered `service_id`. For ScaiLabs services, this is baked into the SCSG registry. Third parties email platform@scailabs.ai — the flow lands an entry in the static registry plus a ScaiKey OAuth app.
- OAuth2 `client_credentials` from ScaiKey against your service identity. Follow the ScaiKey docs — the shape you get is a JWT with `sub = cli_<service_id>_<instance>` and `token_type = "client_credentials"`.
- The user's tenant must have opted your service in (`allow_scsg_<service_id>` set on `mod_scaiclip_tenant_policy`). Tenant admins do this from the admin UI.

## Choose your capabilities

Ask for the minimum you need. Users see every capability in the consent prompt — overreach reads as untrustworthy.

| Bit | Name | When you need it |
|---|---|---|
| `0x01` | READ | Any service that reads what the user copied |
| `0x02` | WRITE | Any service that puts things back into the clipboard |
| `0x04` | CLAIM | You consume `ONE_SHOT` clips (secrets, MFA codes) |
| `0x08` | SCRATCH_READ | You read large files from central storage |
| `0x10` | SCRATCH_WRITE | You upload large content back to central storage |
| `0x20` | PUBLIC_SHARE | You mint public share URLs on behalf of the user |

Combine with `|`. Example: a voice assistant that reads and writes: `CAP_READ | CAP_WRITE` = `0x03`.

## Use the reference client

The Python reference client (`modules.scaiclip.services.scsg_client.SCSGClient` in the ScaiGrid repo, distributable as a package once we cut one) wraps the WS protocol so you don't touch CBOR framing.

```python
from modules.scaiclip.services.scsg_client import SCSGClient, SCSGSessionError
from modules.scaiclip.services.scsg_registry import CAP_READ, CAP_WRITE

SCAIGRID_URL = "https://scaigrid.scailabs.ai"
SCSG_JWT     = "..."  # your client_credentials token

async def handle_voice_call(user_id: str, peer_email: str):
    async with SCSGClient(base_url=SCAIGRID_URL, token=SCSG_JWT) as client:
        grant = await client.open_session(
            user_id=user_id,
            reason=f"voice call with {peer_email}",
            duration_max_s=3600,
            capabilities=CAP_READ | CAP_WRITE,
        )
        print(f"grant {grant.grant_id} pending — user has "
              f"{(grant.consent_deadline_ms - int(time.time()*1000))//1000}s "
              f"to answer")

        try:
            ready = await client.wait_ready(grant.grant_id, timeout_s=60)
        except SCSGSessionError as exc:
            print(f"user denied or timed out: {exc.reason}")
            return

        print(f"got clipboard for {len(ready.granted_channel_ids)} "
              f"channel(s), expires at {ready.expires_at_ms}")

        # ... do voice-assistant things with the user's clipboard ...

        await client.close_session(
            grant.grant_id, reason="hangup",
        )
```

## The consent prompt

When you call `session.open`, the grid pushes `session.grant_request` to every online device the user owns. The T1 client (native app, browser extension, mobile app) renders a prompt.

Craft the `reason` field like a real user will read it — because they will. Compare:

- Bad: `session=abc123 caps=0x03 scope=user_default`
- Good: `call with alice@example.com`

Every user-facing field:

- **`service_display_name`** — pulled from the registry ("ScaiWave (voice)"). You can't change this per-request.
- **`reason`** — free text you set at `session.open`. Truncated to 512 chars.
- **`capabilities`** — rendered as human-readable lines ("Read what you copied", "Send things back to your clipboard").
- **`duration_max_s`** — rendered as wall-clock ("until 15:42 today" beats "for 3600 seconds").

## Handling the terminal states

Four ways a session ends. Your service must handle all of them.

```python
try:
    ready = await client.wait_ready(grant.grant_id)
    # session active
except SCSGSessionError as exc:
    if "denied" in exc.reason:
        # user tapped Deny
        show_ui("Access declined")
    elif "expired" in exc.reason:
        # consent window elapsed with no answer
        show_ui("Timed out waiting for consent")
    elif "revoked" in exc.reason:
        # user revoked from another device mid-session
        show_ui("Access revoked")
```

Grid also pushes `session.expired` mid-session at TTL. Your client's pump loop handles that — same shape as revoke.

## Multi-session per process

The client tracks multiple grants concurrently. Open one per user session:

```python
call_a_grant = await client.open_session(user_id="usr_alice", …)
call_b_grant = await client.open_session(user_id="usr_bob",   …)

await client.close_session(call_a_grant.grant_id, reason="hangup")
# call_b_grant still active
```

One WS connection, many grants. Reconnect handling is deliberately not built into the reference client — production services should wrap it themselves with backoff.

## Rate limits

You get 5 `session.open` calls per second per `(tenant, service_id)` with a 20-token burst. Beyond that: `E_RATE_LIMITED` with `retry_after_ms`.

You get 100 concurrent sessions per `(tenant, service_id)`. Beyond that: `E_QUOTA_EXCEEDED { scope, used, limit }`.

If you routinely hit these, ask for an uplift.

## Audit trail

Every session you open lands in the tenant's `audit_log`:

- `scaiclip.scsg.session_opened` at request time.
- `scaiclip.scsg.session_granted` when the user consents.
- `scaiclip.scsg.session_closed` (or `denied` / `expired` / `revoked`) at the end.

Tenant admins can grep for `service_id = "your_service"` to see every session your service has opened.

## Common gotchas

1. **You forgot to opt in the tenant.** `session.open` returns `E_FORBIDDEN` with `service_id + tenant_id` in `detail`. Ask the tenant admin to flip `allow_scsg_<your_service>`.
2. **The user has no online device.** `E_NO_ONLINE_DEVICE`. You cannot consent-prompt a user who isn't running any ScaiClip client. Surface a UX like "please open ScaiClip on your phone / desktop".
3. **You requested `CLAIM` but the tenant hasn't opted in for one-shot consumption.** `session.open` succeeds; the first `clip.claim` you attempt returns `E_FORBIDDEN`. Read requires opt-in of `allow_agent_claim` at the tenant.
4. **You touch a channel outside `granted_channel_ids`.** `E_OUT_OF_SCOPE`. The grant scope is enforced on every downstream verb.
5. **You forgot to close.** Grid times out at `duration_max_s` and emits `session.expired`, but leaving stale sessions eats your concurrent-session budget. Always close.

## Related

- [Server-side integrations](../concepts/service-consumers.md) — conceptual overview.
- [SCSG verb reference](../reference/scsg.md) — every verb spec.
- [Error codes](../reference/errors.md) — the `E_*` you'll see.