This guide walks you through building an external tool for ImageGlass 10 β an out-of-process program the host launches and talks to over a named pipe. A tool reacts to the user (read the pixel under the cursor, follow photo navigation, inspect the current photo, drive the viewer, run host commands) instead of teaching the host a new image format.
We'll build it end to end using the ConsoleColorPicker
sample: a console app that connects to the host, logs metadata of the current photo, follows
photo navigation, and prints the RGBA value of any pixel the user clicks in the viewer.
New format, not a feature? If you want to decode an image format ImageGlass can't open, you want a Plugin, not a tool β a native in-process codec. See codec-plugin-development.md.
- How a tool works
- Prerequisites
- Step 1 β Create the project
- Step 2 β Subclass ToolBase
- Step 3 β Start it from Main
- Step 4 β React to lifecycle events
- Step 5 β Call back into the host
- Step 6 β Subscribe to real-time events
- Step 7 β Read the pixel under a click
- Step 8 β Register the tool in igconfig.json
- Step 9 β Build, run, and debug
- Working with the full pixel buffer
- Threading & async rules
- Host API reference
A tool is a normal program (any language can speak the protocol, but the SDK gives you a
ready-made C# base class). ImageGlass launches your executable with a --pipe <name>
argument, and the two sides exchange newline-delimited JSON over a named pipe:
ImageGlass host Your tool (.exe)
βββββββββββββββ ββββββββββββββββ
launch tool.exe --pipe ig_abc123 βββββββΆ process starts
ToolBase.RunAsync(args)
connects the pipe
{"Type":"INIT", β¦} ββββββββββββββββββββΆ OnInitializedAsync()
{"Type":"EXECUTE"} βββββββββββββββββββββΆ OnExecuteAsync(ct)
{"Type":"PHOTO_CHANGED", β¦} ββββββββββββΆ OnPhotoChanged(e)
βββββββββββββββββ HostApi.GetPhotoMetadataAsync() (request)
{"Type":"GET_PHOTO_METADATA", β¦} βββββββΆ (response, matched by RequestId)
{"Type":"POINTER_PRESSED", β¦} ββββββββββΆ OnPointerPressed(e)
βββββββββββββββββ HostApi.ReadPixelAsync(x, y)
{"Type":"SHUTDOWN"} ββββββββββββββββββββΆ OnShutdownAsync()
Two directions:
- Host β tool messages arrive as
OnXxxoverrides on yourToolBasesubclass (OnInitializedAsync,OnExecuteAsync,OnPhotoChanged,OnPointerPressed, β¦). - Tool β host calls go through
HostApi(anIToolHostProxy): read pixels, query photo metadata and the photo list, get/set the selection, run named ImageGlass API methods, read theme info.
The SDK hides the pipe, the JSON framing, and request/response correlation. You override
event hooks and await HostApi methods β it reads like ordinary async C#.
- .NET 10 SDK
- A reference to the
ImageGlass.SDKpackage - A working ImageGlass 10 install to launch the tool (it must be launched by the host β
it needs the
--pipeargument)
A tool is just a console executable that references the SDK β see
ConsoleColorPicker.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>Preview</LangVersion>
<Platforms>x64;ARM64</Platforms>
<RootNamespace>ConsoleColorPicker</RootNamespace>
<AssemblyName>ConsoleColorPicker</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ImageGlass.SDK" Version="*" />
</ItemGroup>
</Project>The sample uses a
<ProjectReference>to the SDK source because it lives in this repo; in your own tool use the<PackageReference>shown above.
Unlike a plugin, a tool has no AOT requirements β it's an ordinary process. (You can publish it with Native AOT if you want a smaller, faster-starting binary; the SDK is AOT-compatible. It's optional.)
All the protocol machinery lives in ToolBase. You subclass
it, give it a ToolId, and override the hooks you care about. Here's the skeleton from
ConsoleColorPickerTool.cs:
using ImageGlass.SDK.Tools;
namespace ConsoleColorPicker;
internal sealed class ConsoleColorPickerTool : ToolBase
{
// Must match the "ToolId" in igconfig.json (Step 8).
public override string ToolId => "Tool_ConsoleColorPicker";
protected override Task OnInitializedAsync() { /* β¦ */ return Task.CompletedTask; }
protected override Task OnExecuteAsync(CancellationToken ct) { /* β¦ */ return Task.CompletedTask; }
protected override void OnPhotoChanged(PhotoChangedEventArgs e) { /* β¦ */ }
protected override void OnPointerPressed(PointerEventArgs e) { /* β¦ */ }
protected override Task OnShutdownAsync() { /* β¦ */ return Task.CompletedTask; }
}ToolBase also exposes a few properties you'll use:
HostApi(IToolHostProxy) β your channel back to the host (Step 5).DataDirectoryβ a per-tool folder the host assigns for caches/local state.CurrentThemeβ the latestThemeInfo, kept up to date by the host.
Your Main constructs the tool and calls RunAsync(args). That parses --pipe <name> from
the arguments, connects the pipe, and runs the message loop until the host sends SHUTDOWN
β see Program.cs:
internal static class Program
{
private static async Task<int> Main(string[] args)
{
Log.Init(); // a file logger β see "Where does output go?" below
Log.Write($"Main() entered. args = [{string.Join(", ", args)}]");
try
{
using var tool = new ConsoleColorPickerTool
{
EnableDebug = args.Contains("--debug"), // trace IPC lifecycleβ¦
DebugLog = Log.Write, // β¦to this sink
};
await tool.RunAsync(args).ConfigureAwait(false);
return 0;
}
catch (Exception ex)
{
Log.Write($"FATAL: {ex}");
return 1;
}
}
}
RunAsyncthrowsArgumentExceptionif there's no--pipeargument β which is exactly what happens if you double-click the exe instead of letting ImageGlass launch it. That's expected; the tool is only meaningful as a host-launched child.
Where does output go? ImageGlass is a GUI process, so a child launched with
UseShellExecute=false inherits no console β Console.WriteLine goes nowhere. The sample
therefore writes every line to a log file next to the executable
(ConsoleColorPicker.log) and, on Windows, also tries AllocConsole so you can watch live.
For your own tool, log to a file (or your own UI) β don't rely on stdout being visible.
The host drives your tool through OnXxx hooks. The three lifecycle hooks:
| Hook | When | Notes |
|---|---|---|
OnInitializedAsync() |
Once, right after the pipe connects | DataDirectory and CurrentTheme are populated. Good place to subscribe to events (Step 6). |
OnExecuteAsync(ct) |
The user invokes the tool's primary action (hotkey/menu) | This is "the user ran my tool." |
OnShutdownAsync() |
The host is disconnecting | Last chance to flush/clean up. |
And the event hooks (no subscription needed) β OnPhotoChanged, OnThemeChanged,
OnColorProfileChanged, OnLanguageChanged. Real-time pointer/selection/frame hooks need a
subscription (Step 6).
The sample logs photo metadata both when the tool is run and whenever the user navigates to a new photo:
protected override async Task OnExecuteAsync(CancellationToken ct)
{
Log.Write("[EXECUTE] User opened the tool.");
await PrintCurrentPhotoAsync().ConfigureAwait(false);
}
protected override void OnPhotoChanged(PhotoChangedEventArgs e)
{
// The event itself carries quick info β no host round-trip needed.
Log.Write("[PHOTO CHANGED]");
if (string.IsNullOrEmpty(e.FilePath)) { Log.Write(" (no photo loaded)"); return; }
Log.Write($" File: {e.FilePath}");
Log.Write($" Size: {e.Width} x {e.Height} px");
Log.Write($" Format: {e.Format ?? "(unknown)"}");
Log.Write($" Frames: {e.FrameCount}{(e.CanAnimate ? " (animated)" : "")}");
// Want richer metadata? Fetch it off the dispatch thread (see Step 5 + Threading).
_ = Task.Run(async () =>
{
try { await PrintCurrentPhotoAsync().ConfigureAwait(false); }
catch (Exception ex) { Log.Write($" Failed to read metadata: {ex}"); }
});
}Notice the split: OnPhotoChanged is a synchronous, void event hook that already
carries the essentials in its PhotoChangedEventArgs. When you want more than the event
provides, you make an async host call β and you do it off the dispatch thread (covered next).
HostApi (an IToolHostProxy) is how the tool asks the
host for things. Every method is async and returns a deserialized result. The sample's
metadata printer calls GetPhotoMetadataAsync:
private async Task PrintCurrentPhotoAsync()
{
ToolPhotoMetadata? meta = await HostApi.GetPhotoMetadataAsync().ConfigureAwait(false);
if (meta is null) { Log.Write(" (no photo loaded)"); return; }
Log.Write($" File: {meta.FilePath}");
Log.Write($" Size: {meta.Width} x {meta.Height} px");
Log.Write($" Format: {meta.Format ?? "(unknown)"}");
Log.Write($" Frames: {meta.FrameCount}{(meta.CanAnimate ? " (animated)" : "")}");
Log.Write($" Alpha: {(meta.HasAlpha ? "yes" : "no")}");
Log.Write($" Bytes: {meta.FileSizeInBytes:N0}");
if (!string.IsNullOrEmpty(meta.ColorProfileName))
Log.Write($" Profile: {meta.ColorProfileName}");
}ToolPhotoMetadata is rich β dimensions (current and original), format, frame count,
alpha, color profile, and a full set of EXIF fields (camera model, exposure, ISO, focal
length, capture date, rating, β¦). See ToolTypes.cs.
What else HostApi can do:
| Call | Purpose |
|---|---|
ReadPixelAsync(x, y) |
Fast single-pixel read at source coordinates (pipe only). |
GetPixelBufferAsync(selectionOnly) |
Full pixel buffer via a memory-mapped file (see below). |
ReleasePixelBufferAsync(buffer) |
Release a buffer acquired above. |
GetPhotoMetadataAsync() |
Metadata of the current photo. |
GetPhotoListAsync() |
All photos in the collection + the current index. |
GetSourceSizeAsync() |
Source image dimensions. |
GetSelectionAsync() / SetSelectionAsync(rect) |
Get/set the selection rectangle (null clears it). |
EnableSelectionAsync(enable) |
Toggle selection mode in the viewer. |
RunApiAsync(apiName, argument?) |
Invoke a named ImageGlass API method (drive the host). |
GetThemeInfoAsync() |
Current theme (dark mode, accent/bg/fg colors). |
SubscribeEventsAsync(subscriptions) |
Opt into real-time events (Step 6). |
Pointer, selection, and frame events are opt-in β they fire constantly, so you only get
them after you ask. Subscribe once in OnInitializedAsync:
protected override async Task OnInitializedAsync()
{
Log.Write("ConsoleColorPicker β connected to ImageGlass");
Log.Write($"DataDirectory: {DataDirectory}");
await HostApi.SubscribeEventsAsync(new ToolEventSubscriptions
{
PointerPressed = true, // we want clicks
// PointerMoved = true,
// SelectionChanged = true,
// FrameChanged = true,
}).ConfigureAwait(false);
}Each flag enables a hook:
| Subscription flag | Fires hook |
|---|---|
PointerMoved |
OnPointerMoved(PointerEventArgs e) |
PointerPressed |
OnPointerPressed(PointerEventArgs e) |
SelectionChanged |
OnSelectionChanged(SelectionEventArgs? e) |
FrameChanged |
OnFrameChanged(int frameIndex) |
Without the matching subscription, these hooks never fire even if you override them.
Now the payoff. When the user clicks, OnPointerPressed fires with both source-image and
client coordinates. The sample rounds the source coordinates and asks the host for the
pixel color:
protected override void OnPointerPressed(PointerEventArgs e)
{
var x = (int)Math.Round(e.SourceX); // source-image coordinates
var y = (int)Math.Round(e.SourceY);
// Event hooks are void β never `await` inside them directly. Hop to a Task
// so the host call doesn't block the read loop (see Threading & async rules).
_ = Task.Run(async () =>
{
try
{
ToolColor color = await HostApi.ReadPixelAsync(x, y).ConfigureAwait(false);
var hex = $"#{color.R:X2}{color.G:X2}{color.B:X2}{color.A:X2}";
Log.Write($"[CLICK] ({x}, {y}) RGBA = ({color.R}, {color.G}, {color.B}, {color.A}) {hex}");
}
catch (Exception ex)
{
Log.Write($"[CLICK] Failed to read pixel at ({x}, {y}): {ex}");
}
});
}PointerEventArgs gives you SourceX/SourceY (image pixels β what you want for
ReadPixelAsync), ClientX/ClientY (viewer coordinates), and Button. ReadPixelAsync
returns a ToolColor(R, G, B, A). That's the whole color picker.
ImageGlass discovers tools through an igconfig.json entry under Tools. The ToolId
must match your ToolBase.ToolId:
Two fields decide whether your tool is wired up at all:
IsIntegratedβ set ittrue. That's what makes this an SDK tool: the host launches the process with--pipe <name>and wires up the two-wayHostApiproxy. With itfalse(or omitted), ImageGlass treats the entry as a plain external program β it just launches the exe with its arguments and gives it no pipe, soToolBase/HostApiwon't work.Hotkeysβ an array of key-combination strings (["Alt+1"],["K"]). Pressing one runs the tool (triggeringOnExecuteAsync). Use[]for no shortcut.
In Arguments you can use the <file> macro. ImageGlass replaces it with the full path of
the currently viewed image when it launches the tool:
{
"ToolId": "Tool_MyTool",
"Executable": "C:\\path\\to\\MyTool.exe",
"Arguments": "--input \"<file>\"", // expands to: --input "C:\Photos\my image.png"
"IsIntegrated": true,
"Hotkeys": ["Alt+1"]
}<file> expands without quotes, so wrap it yourself ("<file>") when the path may
contain spaces. The expanded value arrives in your tool's args (the string[] passed to
Main / RunAsync).
Build the tool:
dotnet build samples/ConsoleColorPicker/ConsoleColorPicker.csproj -c DebugThe exe lands in bin/Debug/net10.0/ConsoleColorPicker.exe. Point your igconfig.json
entry's Executable at it, restart ImageGlass, and press the hotkey (K) or open a photo.
When a tool "does nothing," turn on tracing. Set EnableDebug = true and provide a
DebugLog sink before calling RunAsync β the SDK then logs pipe connection, every received
message, and dispatch enter/exit/failure. This surfaces the usual culprits: a wire-format
mismatch, a swallowed exception in an event handler, or a failed pipe connection.
using var tool = new ConsoleColorPickerTool
{
EnableDebug = args.Contains("--debug"),
DebugLog = Log.Write, // append to a file, or Console.WriteLine
};Common gotchas:
| Symptom | Likely cause |
|---|---|
| Tool exits immediately with a fatal error | Launched directly instead of by ImageGlass β no --pipe argument. |
| Tool runs but nothing happens on click | You didn't SubscribeEventsAsync(... PointerPressed = true ...). |
ToolId "not found" / tool never launches |
ToolId in code β ToolId in igconfig.json, or IsIntegrated isn't true. |
| No visible output | GUI host gives no console β log to a file (the sample writes *.log). |
| Event handler seems to hang the tool | You awaited a host call directly inside a void event hook β hop to Task.Run first. |
ReadPixelAsync is perfect for one pixel, but for whole-image work (histograms, analysis,
exporting) you want the entire buffer. That would be huge over the pipe, so the host shares
it through a memory-mapped file instead. GetPixelBufferAsync returns a
PixelBuffer you must dispose:
PixelBuffer? buf = await HostApi.GetPixelBufferAsync(selectionOnly: false).ConfigureAwait(false);
if (buf is not null)
{
try
{
// Zero-copy read of the mapped memoryβ¦
ReadOnlySpan<byte> pixels = buf.GetPixels(); // Stride * Height bytes
// β¦or wrap it as an SKBitmap (valid only while `buf` is alive):
using SKBitmap bmp = buf.ToSKBitmap();
// buf.Width / buf.Height / buf.Stride / buf.ColorType describe the layout.
}
finally
{
buf.Dispose(); // releases the mapped view
await HostApi.ReleasePixelBufferAsync(buf).ConfigureAwait(false); // tells the host to drop the MMF
}
}Pass selectionOnly: true to get just the pixels inside the current selection rectangle.
Always dispose the PixelBuffer and call ReleasePixelBufferAsync so the host can free
the shared file.
The SDK runs a single read loop on the pipe. Understanding how it dispatches keeps you out of trouble:
- Lifecycle hooks (
OnInitializedAsync,OnExecuteAsync,OnShutdownAsync) areasync Taskβ you canawaithost calls in them directly. - Event hooks (
OnPhotoChanged,OnPointerPressed,OnSelectionChanged, β¦) are synchronousvoid. Don'tawaita host call inside them on the dispatch path β offload toTask.Run(as the sample does for bothOnPhotoChanged's rich-metadata fetch andOnPointerPressed'sReadPixelAsync). This keeps the read loop free to deliver the response your call is waiting on. - Always wrap fire-and-forget work in try/catch. An unhandled exception escaping an
async void-style continuation can tear down the process. EveryTask.Runin the sample has acatchthat just logs. - Request/response correlation is automatic: each
HostApicall gets an incrementingRequestIdand the matching reply resolves yourTask. Fire-and-forget hostβtool events carry noRequestIdand never block the loop.
Everything below lives in the ImageGlass.SDK.Tools namespace.
Base class & host proxy
ToolBaseβ subclass this;ToolId,HostApi,DataDirectory,CurrentTheme,EnableDebug/DebugLog,RunAsync, and allOnXxxhooks.IToolHostProxyβ theHostApisurface (pixels, photo info, selection, theming,RunApiAsync,SubscribeEventsAsync).
Event args & subscriptions
ToolEventSubscriptionsβ opt into pointer/selection/frame events.PhotoChangedEventArgs,PointerEventArgs,SelectionEventArgs,LanguageChangedEventArgs,ThemeInfoβ all inToolTypes.cs.
Data types
ToolColor,ToolRectβ small value types (ToolTypes.cs).ToolPhotoMetadata,ToolPhotoList,ToolPhotoListItemβ photo info.PixelBufferβ the memory-mapped full-image buffer.
Protocol (advanced)
MessageTypesβ the wire message-name constants.ToolMessageβ one JSON object per line on the pipe.
Full sample: samples/ConsoleColorPicker