---
summary: "Bridge a local MCP server to ScaiGrid through a WebSocket \u2014 handshake,\
  \ capability registration, consent, invocation, audit."
title: Run a desktop session
path: tutorials/run-a-desktop-session
status: published
---

You want a ScaiGrid agent to call MCP tools running on a user's machine — files, shell, git, a domain tool — with the user explicitly approving each new touchpoint. This tutorial walks through the WebSocket handshake, the capability registration, and the invocation/consent loop.

In practice the official ScaiLink desktop client does this for you. The walkthrough below is for developers building a new client or integrating ScaiLink into an existing desktop product.

## 1. Discover the auth endpoint

ScaiLink's first endpoint is unauthenticated and tells the client where to send the user for OAuth:

```bash
curl "$SCAIGRID_HOST/v1/scailink/auth/discover"
```

The response includes `authorization_endpoint`, `device_authorization_endpoint`, `token_endpoint`, the JWKS URI, supported scopes (`openid profile email groups`), and `gateway_ws: "/v1/scailink/ws"`. Run the standard OAuth device-code flow against ScaiKey to mint a user JWT, then proceed.

## 2. Open the WebSocket and send session_init

```python
import asyncio, json, websockets

async def run():
    async with websockets.connect(
        "wss://scaigrid.scailabs.ai/v1/scailink/ws",
        additional_headers={"Authorization": f"Bearer {USER_JWT}"},
    ) as ws:
        await ws.send(json.dumps({
            "jsonrpc": "2.0",
            "id": "init-1",
            "method": "scailink/session_init",
            "params": {
                "protocol_version": "scailink/1.0",
                "client_version": "demo-0.1",
                "device_name": "Alice's MacBook",
                "platform": {"os": "darwin", "arch": "arm64", "os_version": "14.5"},
                "capabilities": {
                    "tools": [
                        {
                            "name": "filesystem.read_file",
                            "server": "filesystem",
                            "description": "Read a file from allowed paths",
                            "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}}
                        }
                    ],
                    "resources": [],
                    "prompts": []
                },
                "settings": {"audit_detail_level": "metadata"}
            }
        }))
        ack = json.loads(await ws.recv())
        print("session_id:", ack["result"]["session_id"])
        await loop(ws)

async def loop(ws):
    async for raw in ws:
        msg = json.loads(raw)
        if msg.get("method") == "scailink/tool_invoke":
            tool = msg["params"]["tool"]
            args = msg["params"]["arguments"]
            result = handle_tool_locally(tool, args)
            await ws.send(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": result}))

asyncio.run(run())
```

```javascript
import WebSocket from "ws";

const ws = new WebSocket("wss://scaigrid.scailabs.ai/v1/scailink/ws", {
  headers: { Authorization: `Bearer ${process.env.USER_JWT}` },
});

ws.on("open", () => {
  ws.send(JSON.stringify({
    jsonrpc: "2.0",
    id: "init-1",
    method: "scailink/session_init",
    params: {
      protocol_version: "scailink/1.0",
      client_version: "demo-0.1",
      device_name: "Alice's MacBook",
      platform: { os: "darwin", arch: "arm64", os_version: "14.5" },
      capabilities: { tools: [], resources: [], prompts: [] },
      settings: { audit_detail_level: "metadata" },
    },
  }));
});

ws.on("message", (raw) => {
  const msg = JSON.parse(raw.toString());
  if (msg.method === "scailink/tool_invoke") {
    const result = handleToolLocally(msg.params.tool, msg.params.arguments);
    ws.send(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }));
  }
});
```

```bash
# Pure-bash version is impractical for the persistent connection; use wscat for a smoke test:
wscat -c "wss://scaigrid.scailabs.ai/v1/scailink/ws" \
  -H "Authorization: Bearer $USER_JWT" \
  -x '{"jsonrpc":"2.0","id":"init-1","method":"scailink/session_init","params":{"protocol_version":"scailink/1.0","device_name":"test","platform":{"os":"linux"},"capabilities":{"tools":[],"resources":[],"prompts":[]}}}'
```

The first frame after the handshake must be `scailink/session_init`. Anything else closes the socket with code 4002.

## 3. Heartbeats and reconnection

The session_init_ack tells you `heartbeat_interval_ms` (default 30000) and `grace_period_ms` (default 120000). Send a `scailink/heartbeat` notification every interval. If the WebSocket drops, you have the grace window to reconnect and resume — re-open the socket, re-send `session_init` with the same device name and platform, and the server resumes the existing session id rather than creating a new one.

## 4. Update the catalog mid-session

When the local MCP server set changes — a new server starts, a tool is removed — send `scailink/catalog_update` with `added` and `removed` rather than reconnecting:

```python
await ws.send(json.dumps({
    "jsonrpc": "2.0",
    "id": "cat-1",
    "method": "scailink/catalog_update",
    "params": {
        "added": {"tools": [...], "resources": [], "prompts": []},
        "removed": {"tools": [], "resources": [], "prompts": []},
        "servers_changed": ["filesystem"]
    }
}))
```

## 5. Handle consent prompts

When an invocation hits a tool that isn't auto-approved, ScaiLink sends a `scailink/consent_request` frame. Surface it in your client UI — show the user what the agent wants to do, ask for approve/deny, then reply:

```python
await ws.send(json.dumps({
    "jsonrpc": "2.0",
    "id": "consent-resp-1",
    "method": "scailink/consent_response",
    "params": {"request_id": consent_request_id, "decision": "approved"}
}))
```

`decision` is `approved` or `denied`. Approval can be one-shot, or with a TTL if your UI offers "approve for the next hour" — the server-side policy handles the rest.

## 6. Invoke from the server side

Once the client is connected, REST callers in ScaiGrid can invoke registered tools. The tenant must allow `scailink:invoke`:

```bash
curl -X POST "$SCAIGRID_HOST/v1/modules/scailink/users/$USER_ID/tools/filesystem.read_file/invoke" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"arguments": {"path": "/Users/alice/notes.md"}, "timeout": 30}'
```

The REST call blocks until the desktop client replies (or the timeout fires). Behind the scenes the gateway pushes `scailink/tool_invoke` over the WebSocket and waits for the matching response by `id`.

## 7. Verify in the audit log

Every invocation lands in the audit log with the action, target name, status, duration, and the device that served it:

```bash
curl "$SCAIGRID_HOST/v1/modules/scailink/users/$USER_ID/audit?limit=20" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY"
```

The detail level was set on the original `session_init`. `metadata` (default) records names and arguments but not result content; `full` records everything; `off` keeps only the action skeleton.

## Common gotchas

- **First frame must be session_init.** A heartbeat or catalog update sent first closes the socket with code 4002.
- **Device id is derived from device_name + platform.** Two clients with the same name on the same OS map to the same `device_id`; pick distinctive names.
- **Grace period is 120 seconds.** If you reconnect later than that you start a fresh session and the catalog has to be re-sent.
- **JWT scope.** The JWT must include the `groups` claim so the server can resolve ScaiGrid permissions.
