Automate everything.
halolink.indicalab.jp.What's in this manual
| Section | What you'll learn |
|---|---|
| Architecture | How the IdP, GraphQL server, and image tile server connect |
| Authentication | OIDC client-credentials flow, getting a bearer token |
| Queries | Read images, studies, jobs, settings, metadata fields |
| Mutations | Catalog images, create annotations, schedule analysis jobs |
| Subscriptions | Real-time WebSocket events for barcode scan, cataloging, job lifecycle |
| Setup guides | Step-by-step for Python (aiohttp + gql) and .NET 8 (Hot Chocolate / HttpClient) |
halolink.indicalab.jp using the bearer token in the top bar. Paste your own token, or use the demo read-only token pre-filled above.
Architecture Overview
The HALO Link API stack has three services. Understanding how they relate saves debugging time.
/idsrv/connect/token/graphqlJWT-gated tiles
Identity Provider (IdP)
Handles authentication via OpenID Connect. Your app presents a client_id + client_secret and receives a short-lived JWT access token. This token is then attached as a Bearer header on every GraphQL request.
GraphQL API
The single endpoint for all data operations. Use HTTPS POST for queries and mutations. Use WSS (WebSocket) for subscriptions and long-lived sessions — the Apollo subprotocol is required.
Image Server (Deep Zoom)
A separate tile server for whole-slide images. You first call GraphQL to get an image-specific JWT, then use that JWT to fetch pixel tiles directly from the image server. This keeps image access auditable and scoped per-image.
GraphQL endpoint:
https://halolink.indicalab.jp/graphqlInteractive workbench:
https://halolink.indicalab.jp/graphql/workbenchToken endpoint:
https://halolink.indicalab.jp/idsrv/connect/token
Authentication
HALO uses the OAuth 2.0 Client Credentials flow. There are no user logins — your service gets a token by proving its identity with a client ID and secret.
Step 1 — Create a service client (admin, one-time)
Run this on the Windows server where the Identity Provider is installed:
# 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 {}
https://dev.indicalab.jp in allowed_cors_origins for integra_INDICA-CUSTOM in its config for browser token requests to work.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.request( method="post", url=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"]
using System.Net.Http; const string CLIENT_ID = "integra_INDICA-CUSTOM"; const string CLIENT_SECRET = "cRaOX+2LW4gjrXHmlaKlag=="; const string TOKEN_URL = "https://halolink.indicalab.jp/idsrv/connect/token"; var client = new HttpClient(); var resp = await client.PostAsync(TOKEN_URL, new FormUrlEncodedContent(new[] { KeyValuePair.Create("client_id", CLIENT_ID), KeyValuePair.Create("client_secret", CLIENT_SECRET), KeyValuePair.Create("scope", "serviceuser graphql"), KeyValuePair.Create("grant_type", "client_credentials"), })); var json = JsonSerializer.Deserialize<TokenResponse>( await resp.Content.ReadAsStringAsync()); var token = json.AccessToken; // use as Bearer header
API Workbench
The GraphiQL workbench at halolink.indicalab.jp/graphql/workbench is the fastest way to prototype queries before writing code.
| Panel | What it does |
|---|---|
| Left — Query Editor | Write GraphQL. Hit ▶ to execute. PRETTIFY auto-formats. HISTORY recalls past queries. |
| Right — Schema Browser | Click the green SCHEMA tab to browse all queries, mutations, subscriptions, and types with their argument signatures. |
| Bottom — Variables & Headers | QUERY VARIABLES: pass {"pk": 42} JSON into $pk variables. HTTP HEADERS: set Authorization: Bearer <token> here. |
| COPY CURL button | Converts the current query to a cURL command you can paste into a terminal or script. |
Setting your token in the workbench
{"Authorization": "Bearer YOUR_TOKEN_HERE"}GraphQL Concepts
| Concept | Description | Tag |
|---|---|---|
| Query | Read-only. Ask for exactly the fields you need — no over-fetching. Parameterise with $vars. | Query |
| Mutation | Write operations. Return the modified node plus a failed list for partial errors. | Mutation |
| Subscription | Long-lived WebSocket. Server pushes events as they happen — no polling. | Sub |
IDs vs Primary Keys
| Field | Type | Use when |
|---|---|---|
id | ID! (Base64 Relay node ID) | Connecting objects in mutations, subscriptions, references across types |
pk | Int! (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 } } } }
| Operator | Meaning |
|---|---|
EQ / NEQ | Equal / Not equal |
GT / GTE / LT / LTE | Numeric/date comparison |
CONTAINS / NCONTAINS | String substring match |
STARTS / ENDS | Prefix / suffix |
IN_SET | Semicolon-separated set membership |
LIKE | SQL LIKE syntax (% wildcards) |
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
| Field | Type | Notes |
|---|---|---|
id | ID! | Relay node ID — use this in mutations |
pk | Int! | Database integer — matches HALO's UI pk |
location | String | File system path to the slide file |
barcode | String | Barcode value if scanned |
stain | String | Stain type e.g. H&E, IHC |
width / height | Int | Pixel dimensions of the whole slide |
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);
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" } };
Query Analysis Job Progress
Poll a running job with jobById. The condition field cycles through QUEUED → RUNNING → COMPLETE (or FAILED / CANCELLED).
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" } };
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.
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
| Field | Meaning |
|---|---|
mutated[].node | The newly created image record(s) |
failed[].error | Error message if the file path wasn't accessible, or the study ID was invalid |
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]...]}" } } };
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 } };
id and poll with jobById (see Job Progress query) or subscribe to analysisFinishedEvent for a push notification.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.
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));
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}"));
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
cd research && setup.bat — creates a virtual env and installs all depssettings.yml
authorization: hostname: halolink.indicalab.jp client_id: example_WHERE-IS-WALDO client_secret: your-secret-here
.\Scripts\activate.bat then python main.py — should print an access tokenpython .\examples\query_image.py 42Client 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()
.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);
Troubleshooting & Tips
| Symptom | Fix |
|---|---|
401 Unauthorized | Check (1) IdP service is running, (2) client_id/secret match local-production.yml exactly, (3) scopes include both serviceuser and graphql. |
| SSL/TLS certificate error | Python examples use ssl.SSLContext(ssl.PROTOCOL_TLS) without certificate verification. In production use ssl.create_default_context() with your CA bundle. |
| Subscription receives no events | Confirm 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 error | Mutations 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 long | Default 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 object | The 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 } } }