Integration Manual
Live
dd57240
Connecting…
HALO GraphQL Integration Manual
Build on HALO.
Automate everything.
A hands-on guide to the Indica Labs GraphQL API — with live code you can run directly against halolink.indicalab.jp.
183
Queries
185
Mutations
68
Subscriptions
799
Public Types

What's in this manual

SectionWhat you'll learn
ArchitectureHow the IdP, GraphQL server, auth proxy, and image tile server connect
Authentication (Service)OAuth 2.0 Client Credentials flow for backend scripts and services
Browser Auth + BFFOIDC browser login via an auth proxy running on the HALOLink server — no secrets in the browser
Cloudflare Pages DeployHow to host this manual (and your dev frontend) on Cloudflare Pages
Queries / Mutations / SubscriptionsLive runnable examples for every operation type
Setup guidesStep-by-step for Python and .NET 8
Live execution: the Live Examples queries have a ▶ Run button that fires a real request through the on-server auth proxy, using the session cookie from the Sign in → button in the top bar — no token ever reaches this page's JavaScript. The Authentication (Service) section has its own separate demo using a low-privilege, read-only service credential.
Getting Started › Architecture

Architecture Overview

The full stack including the recommended BFF (Backend For Frontend) proxy layer:

🌐
Browser / Dev App
Cloudflare Pages
cookie auth only
🖥️
Auth Proxy
Runs on the HALOLink server
holds token server-side
GraphQL API
halolink.indicalab.jp
/graphql
🔐
Identity Provider
IdentityServer
/idsrv/connect/token

Identity Provider (IdP)

Handles all authentication. Issues short-lived RS256 JWTs to callers who present valid credentials. For services this is Client Credentials. For browser apps this is OIDC Authorization Code + PKCE, brokered by the auth proxy.

Auth Proxy (HALOLink server)

The recommended layer for any browser-facing integration. It's a small service (Python or .NET 8 — see Browser Auth + BFF) deployed alongside the GraphQL API and Identity Provider on the HALOLink server itself. It handles the OIDC dance, stores the access token server-side, and issues the browser an HttpOnly session cookie. The browser never sees the JWT. The proxy forwards requests to /graphql over the local network by injecting the real Bearer token — no separate Cloudflare account or extra public hop required.

GraphQL API

Single endpoint for all data — HTTPS POST for queries/mutations, WSS for subscriptions. Every request must carry Authorization: Bearer <token> and x-requested-service-version: ^2.0.

Image Server (Deep Zoom)

A separate tile server for whole-slide images. Call GraphQL first to get a per-image JWT, then use it to fetch pixel tiles directly.

Key URLs
GraphQL: https://halolink.indicalab.jp/graphql
Workbench: https://halolink.indicalab.jp/graphql/workbench
Token endpoint: https://halolink.indicalab.jp/idsrv/connect/token
Getting Started › Authentication (Service)

Authentication — Service / Script

For backend services, scripts, and automation — no browser involved. Uses the OAuth 2.0 Client Credentials flow directly against the IdP.

1
Admin creates a service client — generates a client_id and client_secret on the IdP server (one-time).
2
Your service requests a token — POST to /idsrv/connect/token. Receive a JWT valid for ~1 hour.
3
Attach token to every GraphQL request — header: Authorization: Bearer <token> + x-requested-service-version: ^2.0
4
Refresh before expiry — re-request when you get a 401 or when the token's exp claim is near.

Step 1 — Create a service client (admin, one-time)

# Run on the Identity Provider server
cd "C:\Program Files\Indica Labs\Identity Provider"
IndicaLabs.ApplicationLayer.Halo.IdentityProvider.exe reconfigure ^
  --script AddResearchServiceClient ^
  "client_type=myapp;scopes=serviceuser|graphql"
# C:\ProgramData\Indica Labs\Configuration\...\local-production.yml
identity_provider:
  clients:
    - !OidcClient/ClientCredentials
      id: integra_INDICA-CUSTOM
      scopes:
        - serviceuser
        - graphql
      require_client_secret: true
      secrets:
        - secret: cRaOX+2LW4gjrXHmlaKlag==
      implementation: !OidcImpl/HALO {}
Security: Store the secret in a secrets manager (Azure Key Vault, Cloudflare Worker Secrets, AWS Secrets Manager) — never in source code or browser bundles.

Step 2 — Request an access token

curl -X POST https://halolink.indicalab.jp/idsrv/connect/token \
  -d "client_id=integra_INDICA-CUSTOM" \
  -d "client_secret=cRaOX+2LW4gjrXHmlaKlag==" \
  -d "scope=serviceuser graphql" \
  -d "grant_type=client_credentials"
import aiohttp, ssl, asyncio

CLIENT_ID     = "integra_INDICA-CUSTOM"
CLIENT_SECRET = "cRaOX+2LW4gjrXHmlaKlag=="
TOKEN_URL     = "https://halolink.indicalab.jp/idsrv/connect/token"

async def request_access_token():
    async with aiohttp.ClientSession() as session:
        async with session.post(TOKEN_URL, data={
            "client_id":     CLIENT_ID,
            "client_secret": CLIENT_SECRET,
            "scope":         "serviceuser graphql",
            "grant_type":    "client_credentials",
        }, ssl=ssl.SSLContext(ssl.PROTOCOL_TLS)) as resp:
            data = await resp.json()
            return data["access_token"]
var resp = await client.PostAsync(TOKEN_URL,
    new FormUrlEncodedContent(new[] {
        KeyValuePair.Create("client_id",     "integra_INDICA-CUSTOM"),
        KeyValuePair.Create("client_secret", "cRaOX+2LW4gjrXHmlaKlag=="),
        KeyValuePair.Create("scope",         "serviceuser graphql"),
        KeyValuePair.Create("grant_type",    "client_credentials"),
    }));
var json = JsonSerializer.Deserialize<TokenResponse>(await resp.Content.ReadAsStringAsync());
return json.AccessToken;
Getting Started › Browser Auth + BFF

Browser Auth — On-Server Proxy

Browser apps can't safely hold a client secret, and a full user-delegated session needs a real login — not a shared service account. The proxy that brokers this now runs on the HALOLink server itself (a small Python or .NET 8 service, deployed next to the GraphQL API and Identity Provider), instead of as a separate Cloudflare Worker. Same machine, same network, one fewer hop, no external account to manage.

🌐
Browser
HttpOnly session cookie
never sees the JWT
🖥️
Auth Proxy
Runs on the HALOLink server
Python or .NET 8
IdP + GraphQL
/idsrv + /graphql
same host, localhost call

Flow — per-user login

1
Browser hits /auth-proxy/login — the proxy generates a PKCE code_verifier/code_challenge pair and redirects to the IdP's /idsrv/connect/authorize with client_id=halo_link.
2
User logs in at the IdP — normal HALO credentials, MFA if configured. The IdP redirects back to /auth-proxy/callback?code=....
3
Proxy exchanges the code for a token — server-to-server call to /idsrv/connect/token using the verifier. The JWT is held in a server-side session store (memory/Redis), keyed by a random session id.
4
Proxy sets the session cookieSet-Cookie: halo_proxy_session=...; HttpOnly; Secure; SameSite=Lax — and redirects the browser back into the app. No token ever reaches JS.
5
App calls /auth-proxy/graphql instead of /graphql directly — the proxy reads the session cookie, looks up the JWT, and forwards the request with Authorization: Bearer <token> injected.
6
Subscriptions connect to /auth-proxy/graphql-ws — the proxy upgrades to a WebSocket, then proxies frames to the real wss://halolink.indicalab.jp/graphql endpoint, injecting the token into the connection_init payload.

Proxy routes

RoutePurpose
GET /auth-proxy/loginStarts the OIDC Authorization Code + PKCE flow
GET /auth-proxy/callbackExchanges the auth code for a token, opens the session
GET /auth-proxy/sessionReturns {authenticated, expires_at} — never the raw token
POST /auth-proxy/graphqlForwards to /graphql with the Bearer token injected
GET /auth-proxy/graphql-wsWebSocket proxy for subscriptions (graphql-ws protocol)
POST /auth-proxy/logoutClears the session and calls the IdP end-session endpoint

Reference implementation — forwarding a request

@app.post("/auth-proxy/graphql")
async def proxy_graphql(request: Request):
    session = await get_session(request)
    if not session or session.is_expired():
        return JSONResponse({"error": "not_authenticated"}, status_code=401)

    body = await request.body()
    async with httpx.AsyncClient() as client:
        upstream = await client.post(
            "https://localhost/graphql",
            content=body,
            headers={
                "Authorization": f"Bearer {session.access_token}",
                "x-requested-service-version": "^2.0",
                "Content-Type": "application/json",
            },
        )
    return Response(upstream.content, status_code=upstream.status_code,
                    media_type="application/json")
app.MapPost("/auth-proxy/graphql", async (HttpContext ctx, ISessionStore sessions, IHttpClientFactory factory) =>
{
    var session = await sessions.GetAsync(ctx);
    if (session is null || session.IsExpired)
        return Results.Json(new { error = "not_authenticated" }, statusCode: 401);

    var client = factory.CreateClient("upstream");
    var req = new HttpRequestMessage(HttpMethod.Post, "https://localhost/graphql")
    {
        Content = new StreamContent(ctx.Request.Body)
    };
    req.Headers.Authorization = new("Bearer", session.AccessToken);
    req.Headers.Add("x-requested-service-version", "^2.0");
    var upstream = await client.SendAsync(req);
    return Results.Stream(await upstream.Content.ReadAsStreamAsync(), "application/json",
        statusCode: (int)upstream.StatusCode);
});
Full reference implementations: see proxy/python/ (FastAPI) and proxy/dotnet/ (ASP.NET Core 8) in this repository — both implement the full login/callback/session/graphql/graphql-ws/logout route set above, ready to deploy on the HALOLink server.
Why move it onto the HALOLink server instead of a Cloudflare Worker: secrets and sessions never leave the customer's network, there's no external Cloudflare account to provision per deployment, and the proxy can call /graphql over localhost/internal network instead of the public internet.
Try it now: this manual is wired to this exact flow. Click Sign in → in the top bar to log in through the proxy, then open any page under Live Examples and hit ▶ Run — the query goes out as POST /auth-proxy/graphql with your session cookie, no token ever touches this page's JavaScript.
Getting Started › Cloudflare Pages Deploy

Hosting This Manual on Cloudflare Pages

This file is a single static index.html — no build step, no server-side rendering. Cloudflare Pages (or any static host) can serve it directly. The only thing that has to stay reachable is the auth proxy on the HALOLink server.

1
Push this repo (or just index.html) to a GitHub/GitLab repo, or drag-and-drop the file in the Cloudflare dashboard under Workers & Pages → Create → Pages → Upload assets.
2
No build command needed — leave the build output directory as / (project root).
3
Point a custom domain at the Pages project if you want a stable URL, e.g. docs.indicalab.jp.
Cross-origin is supported, but allow-listed: the proxy sets its session cookie with SameSite=None; Secure and answers CORS preflights with Access-Control-Allow-Origin + Allow-Credentials — but only for the single origin in AuthProxy:AllowedOrigin (appsettings.json / AUTH_PROXY_ALLOWED_ORIGIN for the Python build), which defaults to https://dev.indicalab.jp. A Cloudflare Pages domain (e.g. docs.indicalab.jp or a *.pages.dev preview URL) won't be on that list automatically — add it to AllowedOrigin (currently a single string, so a new Pages preview URL per branch would need to be added manually) on the proxy before live queries will work from it. The Authentication (Service) demo button is a separate case: the IdP's token endpoint sends no CORS headers for any origin, so that one only ever works when the page is loaded from halolink.indicalab.jp itself.
Why host separately at all: Cloudflare Pages gives free TLS, a CDN, and preview deploys per branch for the docs site — useful if a separate team iterates on the manual independently of the HALOLink server's own release cycle.
Getting Started › API Workbench

API Workbench

The GraphiQL workbench at halolink.indicalab.jp/graphql/workbench is the fastest way to prototype queries before writing code.

PanelWhat it does
Left — Query EditorWrite GraphQL. Hit ▶ to execute. PRETTIFY auto-formats. HISTORY recalls past queries.
Right — Schema BrowserClick the green SCHEMA tab to browse all queries, mutations, subscriptions, and types with their argument signatures.
Bottom — Variables & HeadersQUERY VARIABLES: pass {"pk": 42} JSON into $pk variables. HTTP HEADERS: set Authorization: Bearer <token> here.
COPY CURL buttonConverts the current query to a cURL command you can paste into a terminal or script.
Workflow tip: prototype in the workbench → verify the response shape → copy the GQL string into your Python or .NET code. The schema browser autocomplete is your best friend.

Setting your token in the workbench

1
Open the workbench URL in your browser.
2
Click HTTP HEADERS at the bottom.
3
Enter: {"Authorization": "Bearer YOUR_TOKEN_HERE"}
4
Run any query — you should see live data in the right panel.
Getting Started › GraphQL Concepts

GraphQL Concepts

ConceptDescriptionTag
QueryRead-only. Ask for exactly the fields you need — no over-fetching. Parameterise with $vars.Query
MutationWrite operations. Return the modified node plus a failed list for partial errors.Mutation
SubscriptionLong-lived WebSocket. Server pushes events as they happen — no polling.Sub

IDs vs Primary Keys

FieldTypeUse when
idID! (Base64 Relay node ID)Connecting objects in mutations, subscriptions, references across types
pkInt! (database integer)Looking up from HALO's own UI, logs, or external LIMS systems

Pagination (Relay Cursor Pattern)

query GetImages($first: Int, $after: Cursor) {
  images(first: $first, after: $after) {
    totalCount
    pageInfo { hasNextPage endCursor }
    edges {
      cursor
      node { id pk location tag }
    }
  }
}

Filtering with where clauses

query FilterImages {
  images(first: 25, query: {
    where: {
      q: [{ f: "location", pred: { op: CONTAINS, v: "sample" } }]
    }
    orderBy: {
      q: [{ f: "createdTime", dir: DESC }]
    }
  }) {
    totalCount
    edges { node { id location createdTime } }
  }
}
OperatorMeaning
EQ / NEQEqual / Not equal
GT / GTE / LT / LTENumeric/date comparison
CONTAINS / NCONTAINSString substring match
STARTS / ENDSPrefix / suffix
IN_SETSemicolon-separated set membership
LIKESQL LIKE syntax (% wildcards)
Live Examples › Query

Query Image by Primary Key

The most common starting point. Use imageByPk when you know the integer pk from HALO's database or UI. Returns the image's location path, barcode, stain, and metadata.

query GetImage($pk: Int!) {
  imageByPk(pk: $pk) {
    id
    pk
    location
    barcode
    tag
    stain
    width
    height
  }
}

// Variables
{ "pk": 1 }
from gql import gql, Client
from gql.transport.aiohttp import AIOHTTPTransport

transport = AIOHTTPTransport(
    url="https://halolink.indicalab.jp/graphql",
    headers={"Authorization": f"Bearer {token}"}
)

async with Client(transport=transport) as session:
    result = await session.execute(
        gql("""
        query GetImage($pk: Int!) {
          imageByPk(pk: $pk) {
            id pk location barcode tag stain width height
          }
        }
        """),
        variable_values={"pk": 1}
    )
    print(result["imageByPk"]["location"])
using System.Net.Http.Json;

var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
    new("Bearer", token);

var payload = new {
    query = @"query GetImage($pk: Int!) {
      imageByPk(pk: $pk) {
        id pk location barcode tag stain width height
      }
    }",
    variables = new { pk = 1 }
};

var resp = await client.PostAsJsonAsync(
    "https://halolink.indicalab.jp/graphql", payload);
var json = await resp.Content.ReadAsStringAsync();

Return fields explained

FieldTypeNotes
idID!Relay node ID — use this in mutations
pkInt!Database integer — matches HALO's UI pk
locationStringFile system path to the slide file
barcodeStringBarcode value if scanned
stainStringStain type e.g. H&E, IHC
width / heightIntPixel dimensions of the whole slide
Live Examples › Query

Query Study by Primary Key

Studies are the top-level containers for images in HALO. Use studyByPk to retrieve a study and its metadata.

query GetStudy($pk: Int!) {
  studyByPk(pk: $pk) {
    pk
    id
    name
    description
    createdTime
    permission
  }
}

// Variables
{ "pk": 1 }
result = await session.execute(
    gql("""
    query GetStudy($pk: Int!) {
      studyByPk(pk: $pk) {
        pk id name description createdTime permission
      }
    }
    """),
    variable_values={"pk": 1}
)
study = result["studyByPk"]
print(f"{study['name']} — {study['createdTime']}")
var payload = new {
    query = @"query GetStudy($pk: Int!) {
      studyByPk(pk: $pk) {
        pk id name description createdTime permission
      }
    }",
    variables = new { pk = 1 }
};
var resp = await client.PostAsJsonAsync(
    "https://halolink.indicalab.jp/graphql", payload);
Live Examples › Query

Query Analysis Settings by Name

Find a saved analysis algorithm profile by its savedName. The returned id is what you pass to scheduleAnalysis.

query FindSettings($name: String!) {
  settings(query: {
    where: {
      q: { f: "savedName", pred: { op: EQ, v: $name } }
    }
  }) {
    edges {
      node {
        pk
        id
        algorithmName
        savedName
      }
    }
  }
}

// Variables
{ "name": "Shared Classifier" }
result = await session.execute(
    gql("""
    query FindSettings($name: String!) {
      settings(query: {
        where: { q: { f: "savedName", pred: { op: EQ, v: $name } } }
      }) {
        edges { node { pk id algorithmName savedName } }
      }
    }
    """),
    variable_values={"name": "Shared Classifier"}
)
nodes = [e["node"] for e in result["settings"]["edges"]]
var payload = new {
    query = @"query FindSettings($name: String!) {
      settings(query: {
        where: { q: { f: ""savedName"", pred: { op: EQ, v: $name } } }
      }) {
        edges { node { pk id algorithmName savedName } }
      }
    }",
    variables = new { name = "Shared Classifier" }
};
Live Examples › Query

Query Analysis Job Progress

Poll a running job with jobById. The condition field cycles through QUEUED → RUNNING → COMPLETE (or FAILED / CANCELLED).

Tip: For real-time progress without polling, use the analysisProgressEvent subscription instead.
query GetJobProgress($id: ID!) {
  jobById(id: $id) {
    pk
    id
    settingsSavedName
    progress {
      condition
      percentComplete
      errorMessage
    }
    image { id pk location }
  }
}

// Variables — replace with a real job ID
{ "id": "QW5hbHlzaXNKb2I6MTIz" }
import asyncio

async def poll_job(session, job_id):
    while True:
        result = await session.execute(
            gql("""
            query GetJobProgress($id: ID!) {
              jobById(id: $id) {
                progress { condition percentComplete errorMessage }
              }
            }
            """),
            variable_values={"id": job_id}
        )
        prog = result["jobById"]["progress"]
        print(f"{prog['condition']} — {prog['percentComplete']}%")
        if prog["condition"] in ("COMPLETE", "FAILED", "CANCELLED"):
            break
        await asyncio.sleep(5)
var payload = new {
    query = @"query GetJobProgress($id: ID!) {
      jobById(id: $id) {
        pk id settingsSavedName
        progress { condition percentComplete errorMessage }
      }
    }",
    variables = new { id = "QW5hbHlzaXNKb2I6MTIz" }
};
Live Examples › Mutation

Mutation Catalog an Image

Register a whole-slide image file into HALO's database. Provide the UNC/network path and optionally link to an existing study.

Write operation: This creates a real database record. Use a test study in non-production environments.
mutation CatalogImage($path: String!, $studyId: ID!) {
  catalogImage(input: {
    location: $path
    studyId: $studyId
  }) {
    mutated {
      node { pk id location }
    }
    failed { error }
  }
}

// Variables
{
  "path": "\\\\server\\share\\images\\slide001.svs",
  "studyId": "U3R1ZHk6NQ=="
}
result = await session.execute(
    gql("""
    mutation CatalogImage($path: String!, $studyId: ID!) {
      catalogImage(input: { location: $path, studyId: $studyId }) {
        mutated { node { pk id location } }
        failed  { error }
      }
    }
    """),
    variable_values={
        "path":    r"\\server\share\images\slide001.svs",
        "studyId": "U3R1ZHk6NQ=="
    }
)
if result["catalogImage"]["failed"]:
    print("Errors:", result["catalogImage"]["failed"])
else:
    node = result["catalogImage"]["mutated"][0]["node"]
    print(f"Cataloged pk={node['pk']}")
var payload = new {
    query = @"mutation CatalogImage($path: String!, $studyId: ID!) {
      catalogImage(input: { location: $path, studyId: $studyId }) {
        mutated { node { pk id location } }
        failed  { error }
      }
    }",
    variables = new {
        path    = @"\\server\share\images\slide001.svs",
        studyId = "U3R1ZHk6NQ=="
    }
};

Response structure

FieldMeaning
mutated[].nodeThe newly created image record(s)
failed[].errorError message if the file path wasn't accessible, or the study ID was invalid
Live Examples › Mutation

Mutation Create Annotation Layer + Draw Region

Annotations are two-step: first create a layer on the image, then draw one or more regions (polygons, rectangles, etc.) on that layer.

# Step 1 — Create a layer
mutation CreateLayer($imageId: ID!) {
  createAnnotationLayer(input: { id: $imageId }) {
    mutated { node { id name } }
    failed  { error }
  }
}

# Step 2 — Draw a region on the layer (pentagon example)
mutation DrawRegion($input: DrawAnnotationRegionInput!) {
  drawAnnotationRegion(input: $input) {
    failed { error }
  }
}

// Variables for Step 2
{
  "input": {
    "layerId": "<layer_id from step 1>",
    "shapeType": "POLYGON",
    "geometry": "{\"type\":\"LineString\",\"coordinates\":[[5000,0],[9755,-3090],[6545,-8090],[3455,-8090],[245,-3090],[5000,0]]}"
  }
}
import math, json

# Compute pentagon vertices
ORIGIN, RADIUS = (5000, -5000), 5000
vertices = [
    [round(ORIGIN[0] + RADIUS * math.sin(2*math.pi*i/5)),
     round(ORIGIN[1] + RADIUS * math.cos(2*math.pi*i/5))]
    for i in range(5)
]
vertices.append(vertices[0])  # close ring
geometry = json.dumps({"type": "LineString", "coordinates": vertices})

# Step 1 — create layer
r1 = await session.execute(gql("""
  mutation CreateLayer($imageId: ID!) {
    createAnnotationLayer(input: { id: $imageId }) {
      mutated { node { id } }
    }
  }
"""), variable_values={"imageId": image_id})
layer_id = r1["createAnnotationLayer"]["mutated"][0]["node"]["id"]

# Step 2 — draw
await session.execute(gql("""
  mutation DrawRegion($input: DrawAnnotationRegionInput!) {
    drawAnnotationRegion(input: $input) { failed { error } }
  }
"""), variable_values={"input": {
    "layerId": layer_id, "shapeType": "POLYGON", "geometry": geometry
}})
// Step 1 — create layer
var step1 = new {
    query = @"mutation CreateLayer($imageId: ID!) {
      createAnnotationLayer(input: { id: $imageId }) {
        mutated { node { id name } }
        failed  { error }
      }
    }",
    variables = new { imageId = imageId }
};
// execute step1, extract layerId from response

// Step 2 — draw region
var step2 = new {
    query = @"mutation DrawRegion($input: DrawAnnotationRegionInput!) {
      drawAnnotationRegion(input: $input) { failed { error } }
    }",
    variables = new {
        input = new {
            layerId   = layerId,
            shapeType = "POLYGON",
            geometry  = "{\"type\":\"LineString\",\"coordinates\":[[5000,0]...]}"
        }
    }
};
Live Examples › Mutation

Mutation Schedule an Analysis Job

Queue a HALO analysis on an image using a saved settings profile. You need the settings id (from the settings query) and the image id.

mutation ScheduleJob(
  $settingsId: ID!
  $imageId: ID!
) {
  scheduleAnalysis(input: {
    settingsId: $settingsId
    analysisDescriptions: [{ imageId: $imageId, fieldOfView: null }]
  }) {
    mutated {
      node {
        id
        pk
        settingsSavedName
        image { id pk }
      }
    }
    failed { error }
  }
}

// Variables
{
  "settingsId": "U2F2ZWRBbGdvcml0aG1BbmFseXNpc1NldHRpbmdzOjE=",
  "imageId":    "SW1hZ2U6NDI="
}
result = await session.execute(
    gql("""
    mutation ScheduleJob($settingsId: ID!, $imageId: ID!) {
      scheduleAnalysis(input: {
        settingsId: $settingsId
        analysisDescriptions: [{ imageId: $imageId, fieldOfView: null }]
      }) {
        mutated { node { id pk settingsSavedName } }
        failed  { error }
      }
    }
    """),
    variable_values={
        "settingsId": settings_id,
        "imageId":    image_id,
    }
)
job_id = result["scheduleAnalysis"]["mutated"][0]["node"]["id"]
# now poll with jobById or subscribe to analysisFinishedEvent
var payload = new {
    query = @"mutation ScheduleJob($settingsId: ID!, $imageId: ID!) {
      scheduleAnalysis(input: {
        settingsId: $settingsId
        analysisDescriptions: [{ imageId: $imageId, fieldOfView: null }]
      }) {
        mutated { node { id pk settingsSavedName } }
        failed  { error }
      }
    }",
    variables = new {
        settingsId = settingsId,
        imageId    = imageId
    }
};
Next step: take the returned job id and poll with jobById (see Job Progress query) or subscribe to analysisFinishedEvent for a push notification.
Live Examples › Subscription

Subscription Barcode Scanned Event

Fires in real time whenever a barcode is scanned or changed on any image in HALO Link. Subscriptions require a WebSocket connection — they don't work over plain HTTP POST.

Transport note: Use the graphql-ws or Apollo subprotocol over wss://halolink.indicalab.jp/graphql. The Run button below simulates the connection.
subscription {
  barcodeScannedEvent {
    imageId
  }
}

# Then fetch image details on each event:
query GetById($id: ID!) {
  imageById(id: $id) { id pk barcode }
}
from gql.transport.websockets import WebsocketsTransport

transport = WebsocketsTransport(
    url="wss://halolink.indicalab.jp/graphql",
    headers={"authorization": f"bearer {token}"},
    subprotocols=[WebsocketsTransport.APOLLO_SUBPROTOCOL,]
)

async with Client(transport=transport) as session:
    async for msg in session.subscribe(gql("""
        subscription { barcodeScannedEvent { imageId } }
    """)):
        image_id = msg["barcodeScannedEvent"]["imageId"]
        # fetch full image details
        image = await session.execute(
            gql('query($id:ID!){imageById(id:$id){barcode}}'),
            variable_values={"id": image_id}
        )
        print(f"Barcode: {image['imageById']['barcode']}")
using GraphQL.Client.Http;
using GraphQL.Client.Serializer.SystemTextJson;

var graphQL = new GraphQLHttpClient(
    new GraphQLHttpClientOptions {
        EndPoint = new Uri("https://halolink.indicalab.jp/graphql"),
        WebSocketEndPoint = new Uri("wss://halolink.indicalab.jp/graphql")
    },
    new SystemTextJsonSerializer()
);
graphQL.HttpClient.DefaultRequestHeaders.Authorization =
    new("Bearer", token);

var sub = graphQL.CreateSubscriptionStream<BarcodeEvent>(
    new GraphQLRequest {
        Query = "subscription { barcodeScannedEvent { imageId } }"
    });
sub.Subscribe(msg => Console.WriteLine(msg.Data.BarcodeScannedEvent.ImageId));
Live Examples › Subscription

Subscription Image Cataloged + Study Events

Two events useful for LIMS integration: react whenever a new image lands in HALO, or whenever an image is added to a study.

# Fires when a new image is cataloged
subscription {
  imageCatalogedEvent {
    imageId
    imageLocation
  }
}

# Fires when an image is added to a study
subscription {
  imageAddedToStudyEvent {
    studyId
    imageId
  }
}
import asyncio

async def on_cataloged(session):
    async for msg in session.subscribe(gql("""
        subscription { imageCatalogedEvent { imageId imageLocation } }
    """)):
        ev = msg["imageCatalogedEvent"]
        print(f"New image: {ev['imageLocation']}")

async def on_added_to_study(session):
    async for msg in session.subscribe(gql("""
        subscription { imageAddedToStudyEvent { studyId imageId } }
    """)):
        ev = msg["imageAddedToStudyEvent"]
        # update LIMS here

async def main():
    session = await create_client_session(token)
    await asyncio.gather(
        asyncio.create_task(on_cataloged(session)),
        asyncio.create_task(on_added_to_study(session)),
    )
// Run two subscriptions concurrently
var sub1 = graphQL.CreateSubscriptionStream<CatalogedEvent>(
    new GraphQLRequest {
        Query = "subscription{imageCatalogedEvent{imageId imageLocation}}"
    });
var sub2 = graphQL.CreateSubscriptionStream<StudyEvent>(
    new GraphQLRequest {
        Query = "subscription{imageAddedToStudyEvent{studyId imageId}}"
    });

sub1.Subscribe(e => Console.WriteLine($"Cataloged: {e.Data}"));
sub2.Subscribe(e => Console.WriteLine($"Added to study: {e.Data}"));
Setup › Python

Python Setup

Official example code: gitlab.com/indica_labs_public/example-code

requirements.txt

aiohttp==3.8.5
gql[all]==3.4.1
pyyaml==6.0.1
requests==2.32.3
urllib3==1.26.20
pillow==11.1.0

Setup steps

1
Clone & setup
cd research && setup.bat — creates a virtual env and installs all deps
2
Configure settings.yml
authorization:
  hostname:      halolink.indicalab.jp
  client_id:     example_WHERE-IS-WALDO
  client_secret: your-secret-here
3
Activate & verify
.\Scripts\activate.bat then python main.py — should print an access token
4
Run examples
python .\examples\query_image.py 42

Client session helper

from gql import Client
from gql.transport.websockets import WebsocketsTransport
import ssl

async def create_client_session(token: str):
    transport = WebsocketsTransport(
        url="wss://halolink.indicalab.jp/graphql",
        headers={"authorization": f"bearer {token}"},
        subprotocols=[WebsocketsTransport.APOLLO_SUBPROTOCOL],
        ssl=ssl.SSLContext(ssl.PROTOCOL_TLS),
    )
    client = Client(transport=transport)
    return await client.connect_async()
Setup › .NET 8

.NET 8 Setup

Use GraphQL.Client for HTTP queries/mutations and WebSocket subscriptions. Pair with System.Text.Json for deserialization.

NuGet packages

<!-- Add to your .csproj -->
<PackageReference Include="GraphQL.Client"                      Version="6.*" />
<PackageReference Include="GraphQL.Client.Serializer.SystemTextJson" Version="6.*" />
<PackageReference Include="Microsoft.Extensions.Http"              Version="8.*" />

# Or via CLI
dotnet add package GraphQL.Client
dotnet add package GraphQL.Client.Serializer.SystemTextJson

Client factory (DI-friendly)

using GraphQL.Client.Http;
using GraphQL.Client.Serializer.SystemTextJson;

public static class HaloClientFactory
{
    public static GraphQLHttpClient Create(string token)
    {
        var options = new GraphQLHttpClientOptions
        {
            EndPoint          = new Uri("https://halolink.indicalab.jp/graphql"),
            WebSocketEndPoint = new Uri("wss://halolink.indicalab.jp/graphql"),
        };
        var client = new GraphQLHttpClient(options, new SystemTextJsonSerializer());
        client.HttpClient.DefaultRequestHeaders.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
        return client;
    }
}

Executing a query

var graphQL = HaloClientFactory.Create(token);

var request = new GraphQLRequest
{
    Query = @"query GetImage($pk: Int!) {
      imageByPk(pk: $pk) { id pk location barcode stain }
    }",
    Variables = new { pk = 42 }
};

var response = await graphQL.SendQueryAsync<ImageResponse>(request);
Console.WriteLine(response.Data.ImageByPk.Location);
Setup › Troubleshooting

Troubleshooting & Tips

SymptomFix
401 UnauthorizedCheck (1) IdP service is running, (2) client_id/secret match local-production.yml exactly, (3) scopes include both serviceuser and graphql.
SSL/TLS certificate errorPython examples use ssl.SSLContext(ssl.PROTOCOL_TLS) without certificate verification. In production use ssl.create_default_context() with your CA bundle.
Subscription receives no eventsConfirm APOLLO_SUBPROTOCOL is set. Verify the event is actually triggering in HALO Link (e.g. physically scan a barcode to test barcodeScannedEvent).
Mutation returns wrong ID type errorMutations expect the opaque Base64 id (type ID!), not the integer pk. Always read back id from a query before passing to a mutation.
Large dataset takes too longDefault pagination returns a page at a time. Pass first: -1 to get all records — but use with caution on large collections.
null returned for a known objectThe token may not have permission to that resource, or the pk/id doesn't exist in this environment.

Filtering quick reference

# Single filter
query: { where: { q: { f: "location", pred: { op: CONTAINS, v: "sample" } } } }

# Multiple filters (AND)
query: { where: { q: [
  { f: "stain", pred: { op: EQ, v: "H&E" } },
  { f: "tag",   pred: { op: STARTS, v: "2024" } }
] } }

# Nested field path
query: { where: { q: { f: "systemField.name", pred: { op: EQ, v: "Sample" } } } }

# Get all (no pagination limit)
images(first: -1) { edges { node { id location } } }