Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

Run a desktop session

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
1
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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
1
2
3
4
# 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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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"]
    }
}))

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
1
2
3
4
5
6
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
1
2
3
4
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
1
2
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.
Updated 2026-05-18 15:01:30 View source (.md) rev 12