Skip to main content

Tutorials

Each executable reference has C#, Go, Rust, and TypeScript project sources. Source tabs show code from the current public source revision. Dated test records retain the source commit and application revision used by that run. The @glixo/extension-ui authoring helper is separate from the Accessible Theme UI sample.

Provider: build and test an Ollama connection

This walkthrough uses the current public reference snapshot. It builds a provider component, explains connection approval, and summarizes the installed-package fixture's coverage and limits.

1. Get the pinned source and toolchain

Clone the public source and check out the exact revision shown beside the source tabs:

git clone https://github.com/tmedanovic/glixo-community-modules.git
git -C glixo-community-modules checkout 7052b3f954418cff91439236942a6623d75e8907

Choose the project under references/ollama-provider/ and use its README.md. Its reference.json selects the language recipe in packages/extension-sdk/support-matrix.json; that matrix is the command authority, including the final wasm-tools component wit check.

Language Pinned toolchain Recipe
C# .NET 10 reference README; .NET SDK 10.0.301, componentize-dotnet 0.8.0-preview00011 ollama-provider-csharp-v3
Go Go reference README; Go 1.27.1, componentize-go 0.4.3 ollama-provider-go-v3
Rust Rust reference README; Rust/Cargo 1.96.0, wasm32-wasip2, wit-bindgen 0.62.0, wasm-tools 1.239.0 ollama-provider-rust-v3
TypeScript TypeScript reference README; Node.js 22.14.0, npm 10.9.2, TypeScript 6.0.3, Jco 1.35.0, componentize-js 0.23.0, esbuild 0.28.2 ollama-provider-typescript-v3

Use the published CLI for a new project: glxdev new --kind llm-provider --language <csharp|go|rust|typescript>, followed by glxdev check and glxdev build. To build the reference sources directly, use the matching README; the support matrix lists the exact build commands. Each recipe inspects its output with wasm-tools component wit.

The CLI bundles these source templates; the SDK package releases are tracked separately.

2. Review the manifest and approve the connection

The v2 manifest declares the account configuration provider-account and requests one http.connection capability: endpoint ollama, methods GET and POST, and paths /api/tags, /api/show, and /api/chat. The account configuration chooses the host-approved endpoint name and may select a default model. It does not supply an arbitrary URL.

Set contextWindowTokens in that configuration to choose Ollama's context size. The examples default to 8192 tokens and reject values above 32768. Every chat request sends this value as Ollama's options.num_ctx, including requests without sampling options. Keep this value within the memory budget of the machine running Ollama; its model metadata may advertise a much larger context than that machine can hold.

Install the package through the host's supported signed-package flow, review the package identity and requested capability, grant http.connection, then separately approve the account's ollama connection for exactly those methods and paths. The host issues an invocation-bound endpoint handle. Each broker request is checked against that handle and the manifest; a guest-supplied URL or a different path cannot widen authority. Re-consent after changing the manifest scope.

3. Discover a model and make a request

Model listing uses GET /api/tags; model details use POST /api/show with { "model": "..." }; chat uses streaming POST /api/chat. Check the selected model's returned capabilities before including tools, vision, or thinking. Use Ollama's current list models, chat, and show model details contracts; /api/show is a POST.

The guest reads NDJSON incrementally, maps text, reasoning, tool calls, usage, finish and errors to provider events, and cancels/releases the broker response handle when the caller cancels or drops the stream. For tool continuation, preserve the assistant message with its tool call, execute only through the host's normal tool path, then include the matching tool result (tool_name and content) in the next chat request. A model that does not advertise tools in /api/show does not qualify tool use; do not infer tool support from a fixture.

Source: https://github.com/tmedanovic/glixo-community-modules@7052b3f954418cff91439236942a6623d75e8907 · references/ollama-provider/csharp/src/Provider.cs

public static L.ProviderDescriptor Describe(L.ProviderContext context)
    {
        return ResultBoundary(() =>
        {
            var (config, endpoint) = Resolve(context);
            using (config)
            {
                var models = Models(endpoint);
                var selected = config.RootElement.TryGetProperty("model", out var model) ? models.Where(m => m == model.GetString()) : models.Take(64);
                var caps = new HashSet<string>(StringComparer.Ordinal);
                foreach (var name in selected) caps.UnionWith(Capabilities(endpoint, name));
                var advertised = new List<L.ProviderCapability> { new("chat", false), new("streaming", false) };
                foreach (var capability in new[] { ("tools", "tools"), ("vision", "images"), ("thinking", "reasoning") })
                    if (caps.Contains(capability.Item1)) advertised.Add(new(capability.Item2, true));
                return new L.ProviderDescriptor("community.ollama", "Ollama", "0.1.0", models, advertised);
            }
        });
    }

Source: https://github.com/tmedanovic/glixo-community-modules@7052b3f954418cff91439236942a6623d75e8907 · references/ollama-provider/csharp/src/Provider.cs

public static uint Start(L.LlmRequest request, L.ProviderContext context)
    {
        return ResultBoundary(() =>
        {
            var (config, endpoint) = Resolve(context);
            using (config)
            {
                var caps = Capabilities(endpoint, request.model);
                var bytes = SerializeJson(ChatBody(request, caps, ContextWindowTokens(config.RootElement)));
                var stream = new NdjsonStream(new HostBroker(), HttpRequest(endpoint, "/api/chat", "POST", bytes));
                var status = stream.Status();
                if (status is < 200 or > 299) { stream.Dispose(); throw new InvalidOperationException($"ollama_http_{status}"); }
                var handle = unchecked((uint)Interlocked.Increment(ref _nextHandle));
                Sessions[handle] = new Session(stream, request.requestId);
                return handle;
            }
        });
    }

4. Run the available checks

All four signed packages passed offline and local Gemma tests using a source-built protocol-2 runner. The live tests completed a tool call, returned its result to the model, and received the final response in each language. Stable Glixo 0.7.8 still ships protocol 1, so these guests need a newer app release.

The local model checks used bounded context settings. They confirm the provider flow against Gemma, but do not mean protocol 2 is available in a released Glixo app.

Troubleshooting

Service: publish a durable event

The Mail Watch reference uses scheduled Microsoft Graph delta reads, stores opaque checkpoints, and publishes a deduplicated mail.received event. Metadata access and message-body access are separate grants. Events do not start agent work or send mail by themselves.

Source: https://github.com/tmedanovic/glixo-community-modules@7052b3f954418cff91439236942a6623d75e8907 · references/mail-watch/csharp/MailWatch.cs

* A bounded wake reads only Inbox metadata through the host HTTP broker.
     * The handler never reads body content, sends mail, or follows a guest-selected host.
     */
    public static MailServiceResult Handle(MailServiceRequest request, IBroker broker, IScopedState state)
    {
        _ = state;
        if (request.Kind != "backgroundServices" || request.ContributionId != ContributionId)
            throw new InvalidOperationException("contribution_mismatch");
        var operation = request.Input.Operation;
        if (operation is not ("initialize" or "wake" or "health" or "stop"))
            throw new InvalidOperationException("service_operation_invalid");
        var checkpoint = request.Input.Checkpoint ?? new MailCheckpoint();
        if (operation is "health" or "stop") return Result(checkpoint, []);

        var handles = request.Context?.ResourceHandles;
        if (handles is null || !handles.TryGetValue(OAuthHandleName, out var oauthHandle) || string.IsNullOrWhiteSpace(oauthHandle))
            throw new InvalidOperationException("graph_oauth_lease_missing");

        var configuredPages = request.Configuration?.MaxPagesPerWake ?? MaxPagesPerWake;
        if (configuredPages is < 1 or > MaxPagesPerWake) throw new InvalidOperationException("mail_watch_page_limit_invalid");
        var initializing = operation == "initialize" || checkpoint.Initializing || !checkpoint.Initialized;
        var cursor = checkpoint.PendingUrl ?? checkpoint.DeltaUrl ?? InitialDeltaUrl;
        var deltaUrl = checkpoint.DeltaUrl;
        string? pendingUrl = null;
        var events = new List<MailEvent>();

        for (var page = 0; page < configuredPages; page++)
        {
            var document = ReadGraphJson(broker, new BrokerRequest(
                ValidateGraphDeltaUrl(cursor), "GET", [new Header("Accept", "application/json")], null, null,
                5000, MaxPageBytes, 200, 299, null, OAuthHandleName, "Authorization", "Bearer"));
            if (!document.RootElement.TryGetProperty("value", out var values) || values.ValueKind != JsonValueKind.Array)
                throw new InvalidOperationException("graph_delta_value_invalid");
            if (!initializing)
            {
                foreach (var item in values.EnumerateArray())
                {
                    if (item.ValueKind == JsonValueKind.Object && !item.TryGetProperty("@removed", out _))
                    {
                        var emitted = ToMailEvent(item);
                        if (emitted is not null) events.Add(emitted);
                    }
                }
            }

            if (document.RootElement.TryGetProperty("@odata.nextLink", out var next) && next.ValueKind == JsonValueKind.String)
            {
                cursor = ValidateGraphDeltaUrl(next.GetString()!);
                pendingUrl = next.GetString();
                continue;
            }
            if (!document.RootElement.TryGetProperty("@odata.deltaLink", out var finalDelta) || finalDelta.ValueKind != JsonValueKind.String)
                throw new InvalidOperationException("graph_delta_link_missing");
            deltaUrl = ValidateGraphDeltaUrl(finalDelta.GetString()!);
            pendingUrl = null;
            initializing = false;
            break;
        }

        if (pendingUrl is null && initializing) throw new InvalidOperationException("graph_initial_sync_incomplete");
        return Result(new MailCheckpoint
        {
            Initialized = !initializing,
            Initializing = initializing,
            DeltaUrl = deltaUrl,
            PendingUrl = pendingUrl,
        }, events);
    }

Qualification: All four guests pass protocol-2 fixture cases. The TypeScript package also passed a persisted Code.Server scheduler journey using deterministic Graph responses, database-backed grants, encrypted OAuth configuration, checkpoint persistence, outbox deduplication, and event delivery. C#/Go/Rust installed-host journeys and real Graph credentials/mail delivery remain untested. The service remains Preview overall.

Middleware: observe and transform with provenance

The message pipeline separates read-only observation from outgoing-input and assistant-output transforms. The runtime preserves the original transcript and records the package identity, digest, stage, time, and human-readable reason for an applied transform.

Conversation Insights

Conversation Insights is a read-only observer reference. Its implementation source does not establish permission to read message content or prove that the observer is installed in a production host.

Source: https://github.com/tmedanovic/glixo-community-modules@7052b3f954418cff91439236942a6623d75e8907 · references/conversation-insights/csharp/src/GuestImpl.cs

internal static string Analyze(JsonElement input)
    {
        if (Number(input, "schemaVersion") != 1)
            throw new InvalidOperationException("middleware_schema_unsupported");
        var operation = Text(input, "operation");
        if (operation == "committed")
        {
            if (!input.TryGetProperty("committedMessage", out var message) || message.ValueKind != JsonValueKind.Object)
                throw new InvalidOperationException("committed_message_missing");
            var role = Text(message, "role");
            var observation = new CommittedObservation(
                1, 1, role == "user" ? 1 : 0, role == "assistant" ? 1 : 0,
                Count(message, "toolCallCount"), Count(message, "toolResultCount"), Count(message, "attachmentCount"));
            return JsonSerializer.Serialize(new CommittedResult(observation, "insights-recorded"), GuestJsonContext.Default.CommittedResult);
        }

        if (operation == "completed")
        {
            if (!input.TryGetProperty("completion", out var completion) || completion.ValueKind != JsonValueKind.Object)
                throw new InvalidOperationException("completion_missing");
            var hasUsage = completion.TryGetProperty("actualUsage", out var usage) && usage.ValueKind == JsonValueKind.Object;
            var actualUsage = hasUsage ? new UsageSnapshot(
                NullableCount(usage, "inputTokens"), NullableCount(usage, "outputTokens"),
                NullableCount(usage, "cachedTokens"), NullableCount(usage, "reasoningTokens"), NullableCount(usage, "totalTokens")) : null;
            var observation = new CompletedObservation(
                1, Count(completion, "latencyMilliseconds"), Count(completion, "userMessageCount"),
                Count(completion, "assistantMessageCount"), Count(completion, "toolCallCount"),
                Count(completion, "toolResultCount"), Count(completion, "attachmentCount"), hasUsage, actualUsage,
                Count(completion, "estimatedInputCharacters"), Count(completion, "estimatedOutputCharacters"), true);
            return JsonSerializer.Serialize(new CompletedResult(observation, "analysis-complete"), GuestJsonContext.Default.CompletedResult);
        }
        throw new InvalidOperationException("operation_unsupported");
    }

Prompt Redactor

Prompt Redactor demonstrates a bounded message transform. Input and output transforms require their separate grants; an enabled mandatory redaction stage must fail closed when it cannot safely transform.

Source: https://github.com/tmedanovic/glixo-community-modules@7052b3f954418cff91439236942a6623d75e8907 · references/prompt-redactor/csharp/src/GuestImpl.cs

public static string PreviewText(string text, IReadOnlyList<(string Match, string Replacement)> rules)
    {
        ArgumentNullException.ThrowIfNull(text);
        if (rules.Count > 64) throw new InvalidOperationException("redaction_rules_invalid");
        foreach (var (match, replacement) in rules)
        {
            if (string.IsNullOrEmpty(match) || match.EnumerateRunes().Count() > 512 || replacement.EnumerateRunes().Count() > 1024)
                throw new InvalidOperationException("redaction_rule_invalid");
            text = text.Replace(match, replacement, StringComparison.Ordinal);
        }
        return text;
    }

    internal static RedactorResponse Redact(JsonElement input, JsonElement configuration)
    {
        if (Number(input, "schemaVersion") != 1 || Text(input, "operation") != "before-provider")
            throw new InvalidOperationException("operation_unsupported");
        if (!input.TryGetProperty("conversation", out var conversation)
            || !conversation.TryGetProperty("messages", out var messages) || messages.ValueKind != JsonValueKind.Array)
            throw new InvalidOperationException("conversation_missing");
        var rules = ReadRules(configuration);
        var patches = new List<TextPatch>();
        foreach (var message in messages.EnumerateArray())
        {
            if (Text(message, "role") != "user" || !message.TryGetProperty("isHostAuthority", out var authority)
                || authority.ValueKind != JsonValueKind.False || Text(message, "id") is not { } messageId
                || !message.TryGetProperty("parts", out var parts) || parts.ValueKind != JsonValueKind.Array)
                continue;
            foreach (var part in parts.EnumerateArray())
            {
                if (Text(part, "kind") != "text" || Text(part, "id") is not { } partId || Text(part, "text") is not { } original)
                    continue;
                var changed = PreviewText(original, rules.Select(rule => (rule.Match, rule.Replacement)).ToArray());
                if (!string.Equals(changed, original, StringComparison.Ordinal))
                    patches.Add(new TextPatch(messageId, partId, changed));
            }
        }
        return new RedactorResponse(patches, patches.Count == 0 ? "no-change" : "redaction-applied");
    }

Host gate: prove deterministic ordering, cancellation and size budgets, optional versus mandatory failure behavior, safe attachment and tool-call validation, account boundaries, and removal during an in-flight request. Middleware is not advertised as an available public contribution until this installed request path passes.

Sandboxed web UI: Accessible Theme

The Accessible Theme example has four language action guests and a sandboxed settings panel. It does not change Glixo Code's appearance. Windows isolation checks passed 7/7 in a source-built app; the signed package, account grant, and full action journey are still being checked. Linux custom panels are unavailable, and mobile is unsupported. See UI support and limitations. The reusable @glixo/extension-ui controls helper is Preview authoring source, not an installed theme.

Workspace references

These examples use host-issued workspace handles and bounded operations. They do not grant ambient filesystem access and do not claim access beyond the selected workspace.

Workspace Health

Workspace Health shows a bounded workspace inspection path. A truncated host result is a sample, not a complete project total.

Source: https://github.com/tmedanovic/glixo-community-modules@7052b3f954418cff91439236942a6623d75e8907 · references/workspace-health/csharp/src/Handler.cs

public static string Invoke(string requestJson, IWorkspaceReader workspace)
    {
        using var request = JsonDocument.Parse(requestJson, new JsonDocumentOptions { MaxDepth = 32 });
        var root = request.RootElement;
        if (root.GetProperty("kind").GetString() != "tools" || root.GetProperty("contributionId").GetString() != "inspect")
            throw new InvalidOperationException("contribution_mismatch");
        var handle = WorkspaceHandle(root);
        var limit = OptionalInteger(root.GetProperty("input"), "maxFiles", 100, 1, 100);
        var (items, truncated) = ReadInventory(workspace.Search(handle, "", (uint)limit), limit);
        var total = items.Aggregate(0L, (sum, item) => checked(sum + item.Bytes));
        if (total > MaximumJsonInteger) throw new InvalidOperationException("workspace_search_result_invalid");
        var extensions = new SortedDictionary<string, int>(Utf8Comparer.Instance);
        foreach (var item in items)
        {
            var name = item.Path[(item.Path.LastIndexOf('/') + 1)..];
            var dot = name.LastIndexOf('.');
            var extension = dot <= 0 ? "[none]" : name[dot..].ToLowerInvariant();
            extensions[extension] = extensions.GetValueOrDefault(extension) + 1;
        }
        return WriteJson(writer =>
        {
            writer.WriteStartObject();
            writer.WriteNumber("sampledFiles", items.Length);
            writer.WriteNumber("sampledBytes", total);
            writer.WriteBoolean("truncated", truncated);
            writer.WritePropertyName("eligibleFileTotals");
            if (truncated) writer.WriteNullValue();
            else
            {
                writer.WriteStartObject(); writer.WriteNumber("fileCount", items.Length);
                writer.WriteNumber("bytes", total); writer.WriteEndObject();
            }
            writer.WriteStartObject("sampledExtensions");
            foreach (var (extension, count) in extensions) writer.WriteNumber(extension, count);
            writer.WriteEndObject();
            writer.WriteStartArray("largestFiles");
            foreach (var item in items.OrderByDescending(item => item.Bytes)
                .ThenBy(item => item.Path, Utf8Comparer.Instance).Take(10))
            {
                writer.WriteStartObject(); writer.WriteString("path", item.Path);
                writer.WriteNumber("bytes", item.Bytes); writer.WriteEndObject();
            }
            writer.WriteEndArray(); writer.WriteEndObject();
        });
    }

Document Index

Document Index searches selected-workspace relative paths and returns small, bounded excerpts from matching files. It is path matching, not a semantic or full-text index.

Source: https://github.com/tmedanovic/glixo-community-modules@7052b3f954418cff91439236942a6623d75e8907 · references/document-index/csharp/src/Handler.cs

public static string Invoke(string requestJson, IWorkspaceReader workspace)
    {
        using var request = JsonDocument.Parse(requestJson, new JsonDocumentOptions { MaxDepth = 32 });
        var root = request.RootElement;
        if (root.GetProperty("kind").GetString() != "dataSources"
            || root.GetProperty("contributionId").GetString() != "search")
            throw new InvalidOperationException("contribution_mismatch");
        var handle = Inventory.WorkspaceHandle(root);
        var input = root.GetProperty("input");
        var query = input.GetProperty("query").GetString();
        if (string.IsNullOrWhiteSpace(query) || query.Length > 128) throw new InvalidOperationException("query_invalid");
        var limit = Inventory.OptionalInteger(input, "limit", 10, 1, 20);
        var excerptLimit = Inventory.OptionalInteger(input, "excerptBytes", 96, 1, 96);
        // Search matches paths, not contents. The host controls the workspace root and read bound.
        var (matches, truncated) = Inventory.ReadInventory(workspace.Search(handle, query, (uint)limit), limit);
        return Inventory.WriteJson(writer =>
        {
            writer.WriteStartObject(); writer.WriteString("query", query);
            writer.WriteStartArray("items");
            foreach (var item in matches)
            {
                writer.WriteStartObject(); writer.WriteString("path", item.Path); writer.WriteNumber("bytes", item.Bytes);
                if (item.Bytes > 4096)
                {
                    writer.WriteNull("excerpt"); writer.WriteBoolean("excerptTruncated", true);
                }
                else
                {
                    var text = workspace.Read(handle, item.Path, 4096);
                    var bytes = StrictUtf8.GetBytes(text);
                    if (bytes.Length > 4096) throw new InvalidOperationException("workspace_read_result_invalid");
                    var end = Math.Min(bytes.Length, excerptLimit);
                    while (end > 0)
                    {
                        try { StrictUtf8.GetString(bytes, 0, end); break; }
                        catch (DecoderFallbackException) { end--; }
                    }
                    writer.WriteString("excerpt", StrictUtf8.GetString(bytes, 0, end));
                    writer.WriteBoolean("excerptTruncated", bytes.Length > excerptLimit);
                }
                writer.WriteEndObject();
            }
            writer.WriteEndArray(); writer.WriteBoolean("truncated", truncated); writer.WriteEndObject();
        });
    }

Workspace Storage

Workspace Storage demonstrates namespaced key/value operations with a prefix-scoped grant. The guest chooses logical keys; it cannot choose an unrestricted storage prefix.

Source: https://github.com/tmedanovic/glixo-community-modules@7052b3f954418cff91439236942a6623d75e8907 · references/workspace-storage/csharp/src/StorageHandler.cs

public static JsonElement Handle(string requestJson, IScopedState state)
    {
        using var document = JsonDocument.Parse(requestJson);
        var envelope = document.RootElement;
        if (envelope.ValueKind != JsonValueKind.Object
            || ReadString(envelope, "kind") != "tools"
            || ReadString(envelope, "contributionId") != ContributionId)
            throw new InvalidOperationException("contribution_mismatch");
        if (!envelope.TryGetProperty("input", out var input) || input.ValueKind != JsonValueKind.Object)
            throw new InvalidOperationException("input_object_required");
        var operation = ReadString(input, "operation") ?? throw new InvalidOperationException("storage_operation_invalid");
        JsonElement response;
        switch (operation)
        {
            case "get":
            {
                EnsureFields(input, "operation", "key");
                var key = ReadKey(input, "key");
                response = JsonSerializer.SerializeToElement(new GetResponse(operation, key, state.Get(key)), StorageJsonContext.Default.GetResponse);
                break;
            }
            case "set":
            {
                EnsureFields(input, "operation", "key", "value");
                var key = ReadKey(input, "key");
                var value = ReadString(input, "value") ?? throw new InvalidOperationException("value_string_required");
                if (Encoding.UTF8.GetByteCount(value) > MaxValueBytes) throw new InvalidOperationException("value_too_large");
                state.Set(key, value);
                response = JsonSerializer.SerializeToElement(new SetResponse(operation, key, true), StorageJsonContext.Default.SetResponse);
                break;
            }
            case "list":
            {
                EnsureFields(input, "operation", "prefix");
                if (input.TryGetProperty("prefix", out var prefixElement) && prefixElement.ValueKind != JsonValueKind.String)
                    throw new InvalidOperationException("key_prefix_invalid");
                var prefix = ReadString(input, "prefix") ?? "";
                if (prefix.Length > 0 && !ValidKey(prefix)) throw new InvalidOperationException("key_prefix_invalid");
                var fullPrefix = prefix.Length == 0 ? TestPrefix : StorageKey(prefix);
                var keys = state.List(fullPrefix)
                    .Where(key => key.StartsWith(TestPrefix, StringComparison.Ordinal))
                    .Select(key => key[TestPrefix.Length..])
                    .Where(key => ValidKey(key) && key.StartsWith(prefix, StringComparison.Ordinal))
                    .OrderBy(key => key, StringComparer.Ordinal)
                    .Take(100)
                    .ToArray();
                response = JsonSerializer.SerializeToElement(new ListResponse(operation, prefix, keys), StorageJsonContext.Default.ListResponse);
                break;
            }
            case "delete":
            {
                EnsureFields(input, "operation", "key");
                var key = ReadKey(input, "key");
                response = JsonSerializer.SerializeToElement(new DeleteResponse(operation, key, state.Delete(key)), StorageJsonContext.Default.DeleteResponse);
                break;
            }
            default: throw new InvalidOperationException("storage_operation_invalid");
        }
        return response;
    }

MCP: look up an issue through an approved endpoint

Issue Lookup calls one fixed issues.lookup tool through a user-approved Streamable HTTP MCP endpoint. Tool name, HTTP method, and /mcp path are fixed by the reference; input does not select a URL or endpoint handle.

Source: https://github.com/tmedanovic/glixo-community-modules@7052b3f954418cff91439236942a6623d75e8907 · references/issue-lookup-mcp/csharp/IssueLookup.cs

public static string Handle(string requestJson, IBroker broker)
    {
        using var envelope = JsonDocument.Parse(requestJson);
        var root = envelope.RootElement;
        if (Text(root, "kind") != "tools" || Text(root, "contributionId") != "issue-lookup")
            throw new InvalidOperationException("guest_envelope_invalid");
        var input = GetObject(root, "input");
        var issueKey = Text(input, "issueKey");
        if (issueKey is null || !IssueKeyPattern.IsMatch(issueKey)) throw new InvalidOperationException("issue_key_invalid");
        var configuration = GetObject(root, "configuration");
        if (Text(configuration, "endpointName") != "issues") throw new InvalidOperationException("endpoint_name_must_be_issues");
        var context = GetObject(root, "context");
        var endpoint = ResolveEndpoint(context);

        using var requestBuffer = new MemoryStream();
        using (var writer = new Utf8JsonWriter(requestBuffer))
        {
            writer.WriteStartObject();
            writer.WriteString("jsonrpc", "2.0");
            writer.WriteNumber("id", 1);
            writer.WriteString("method", "tools/call");
            writer.WritePropertyName("params");
            writer.WriteStartObject();
            writer.WriteString("name", "issues.lookup");
            writer.WritePropertyName("arguments");
            writer.WriteStartObject();
            writer.WriteString("issueKey", issueKey);
            writer.WriteEndObject();
            writer.WritePropertyName("_meta");
            writer.WriteStartObject();
            writer.WriteString("io.modelcontextprotocol/protocolVersion", "2026-07-28");
            writer.WritePropertyName("io.modelcontextprotocol/clientInfo");
            writer.WriteStartObject();
            writer.WriteString("name", "glixo-issue-lookup");
            writer.WriteString("version", "0.1.0");
            writer.WriteEndObject();
            writer.WriteEndObject();
            writer.WriteEndObject();
            writer.WriteEndObject();
        }
        var body = requestBuffer.ToArray();
        var request = new BrokerRequest(
            endpoint.Url,
            "POST",
            [
                new Header("Accept", "application/json"),
                new Header("MCP-Protocol-Version", "2026-07-28"),
                new Header("Mcp-Method", "tools/call"),
                new Header("Mcp-Name", "issues.lookup")
            ],
            body, "application/json", 15_000, MaxResponseBytes, 200, 299,
            endpoint.Handle, null, null, null);

        var handle = broker.Start(request);
        var completed = false;
        try
        {
            if (broker.Status(handle) != 200) throw new InvalidOperationException("mcp_http_status_invalid");
            var contentType = broker.ResponseHeaders(handle).FirstOrDefault(header =>
                string.Equals(header.Name, "content-type", StringComparison.OrdinalIgnoreCase))?.Value;
            if (contentType is null || !string.Equals(contentType.Split(';', 2)[0].Trim(), "application/json", StringComparison.OrdinalIgnoreCase))
                throw new InvalidOperationException("mcp_content_type_invalid");
            using var response = new MemoryStream();
            var emptyReads = 0;
            while (true)
            {
                var chunk = broker.Read(handle, 16 * 1024);
                if (chunk is null) break;
                if (chunk.Length == 0 && ++emptyReads > 32) throw new InvalidOperationException("mcp_response_stalled");
                if (chunk.Length > 0) emptyReads = 0;
                if (response.Length + chunk.Length > MaxResponseBytes) throw new InvalidOperationException("mcp_response_too_large");
                response.Write(chunk);
            }
            using var document = JsonDocument.Parse(response.ToArray());
            var rpc = document.RootElement;
            if (Text(rpc, "jsonrpc") != "2.0" || !rpc.TryGetProperty("id", out var id) || id.GetInt32() != 1 ||
                rpc.TryGetProperty("error", out _)) throw new InvalidOperationException("mcp_call_failed");
            if (!rpc.TryGetProperty("result", out var result) || !result.TryGetProperty("content", out var content) ||
                content.ValueKind != JsonValueKind.Array || content.GetArrayLength() != 1)
                throw new InvalidOperationException("mcp_result_content_missing");
            var item = content[0];
            var summary = Text(item, "text");
            if (Text(item, "type") != "text" || summary is null || Encoding.UTF8.GetByteCount(summary) > 64 * 1024)
                throw new InvalidOperationException("mcp_result_content_invalid");
            completed = true;
            using var resultBuffer = new MemoryStream();
            using (var writer = new Utf8JsonWriter(resultBuffer))
            {
                writer.WriteStartObject();
                writer.WriteString("issueKey", issueKey);
                writer.WriteString("summary", summary);
                writer.WriteEndObject();
            }
            return Encoding.UTF8.GetString(resultBuffer.ToArray());
        }
        finally
        {
            if (!completed) broker.Cancel(handle);
            broker.Drop(handle);
        }
    }

Host gate: prove the selected account's approved endpoint and connection grant, typed request validation, bounded response handling, cancellation, revoke behavior, and installed-host invocation. A language build or fixture alone does not qualify an external tracker integration.

Workflow node: count words and characters

The workflow-text-stats reference demonstrates an extension tool as a step in a Workflow Designer graph. The four language projects use the same signed text-stats tool contract; the source tabs show its input validation and counting logic.

  1. Create a project from the published @glixo/glxdev 0.3.3 template with glxdev new --kind reference --reference workflow-text-stats --language typescript --output ./workflow-text-stats. Replace typescript with csharp, go, or rust to use another language. Follow the project README to check and build it, then prepare the signed package through the supported package flow. The manifest declares a required text input and wordCount and characterCount outputs. It requests tool.invoke only for text-stats and declares the tool read-only. The pinned public source contains the reference implementation shown in the tabs below.
  2. Install the signed package for the account used by Workflow Designer, then approve its requested tool permission. Eligible installed tools appear in the Designer's extension-node palette.
  3. Add text-stats to a draft workflow. Set includeWhitespace and minimumWordLength in the node settings, or keep their defaults (true and 1).
  4. Bind the required text input to the run input with { "text": "$.text" }. Workflow input mappings select a scalar value from the run input or a preceding node's output.
  5. Publish the workflow and run it with {"text":"Glixo makes tools."}. The step returns {"wordCount":3,"characterCount":18}. Punctuation remains part of a word; minimumWordLength affects only the word count, and includeWhitespace affects only the character count.

This example passed signed installed-host tests in all four languages with the protocol-2 source-built runner and verified embedded pin. Stable Glixo 0.7.8 ships protocol 1, so an app release carrying protocol 2 is needed before this workflow can run there. The Designer keeps each node's settings outside the workflow graph and stores secret values in the host vault. Tools that require confirmation for every call need an approval step and cannot run automatically here.

Source: https://github.com/tmedanovic/glixo-community-modules@7052b3f954418cff91439236942a6623d75e8907 · references/workflow-text-stats/csharp/src/Handler.cs

public static string Invoke(string requestJson)
    {
        if (Encoding.UTF8.GetByteCount(requestJson) > 128 * 1024)
            throw new InvalidOperationException("guest_envelope_invalid");
        try
        {
            using var document = JsonDocument.Parse(requestJson, new JsonDocumentOptions { MaxDepth = 24 });
            var root = document.RootElement;
            if (root.GetProperty("kind").GetString() != "tools" || root.GetProperty("contributionId").GetString() != "text-stats")
                throw new InvalidOperationException("contribution_mismatch");

            var input = root.GetProperty("input");
            if (input.ValueKind != JsonValueKind.Object || !input.TryGetProperty("text", out var textElement) || textElement.ValueKind != JsonValueKind.String)
                throw new InvalidOperationException("text_required");
            var text = textElement.GetString() ?? throw new InvalidOperationException("text_required");
            var includeWhitespace = OptionalBoolean(input, "includeWhitespace", true);
            var minimumWordLength = OptionalInteger(input, "minimumWordLength", 1, 1, MaximumMinimumWordLength);
            var result = Analyze(text, includeWhitespace, minimumWordLength);

            var output = new ArrayBufferWriter<byte>();
            using (var writer = new Utf8JsonWriter(output))
            {
                writer.WriteStartObject();
                writer.WriteNumber("wordCount", result.WordCount);
                writer.WriteNumber("characterCount", result.CharacterCount);
                writer.WriteEndObject();
            }
            return Encoding.UTF8.GetString(output.WrittenSpan);
        }
        catch (InvalidOperationException)
        {
            throw;
        }
        catch (JsonException)
        {
            throw new InvalidOperationException("guest_envelope_invalid");
        }
        catch (KeyNotFoundException)
        {
            throw new InvalidOperationException("guest_envelope_invalid");
        }
    }

    public static StatsResult Analyze(string text, bool includeWhitespace = true, int minimumWordLength = 1)
    {
        if (minimumWordLength < 1 || minimumWordLength > MaximumMinimumWordLength)
            throw new InvalidOperationException("minimum_word_length_out_of_range");

        var wordCount = 0;
        var characterCount = 0;
        var currentWordLength = 0;
        var scalarCount = 0;
        foreach (var rune in text.EnumerateRunes())
        {
            scalarCount++;
            if (scalarCount > MaximumTextScalars) throw new InvalidOperationException("text_too_long");
            if (IsWhiteSpace(rune.Value))
            {
                if (includeWhitespace) characterCount++;
                if (currentWordLength >= minimumWordLength) wordCount++;
                currentWordLength = 0;
            }
            else
            {
                characterCount++;
                currentWordLength++;
            }
        }
        if (currentWordLength >= minimumWordLength) wordCount++;
        return new StatsResult(wordCount, characterCount);
    }

    private static bool OptionalBoolean(JsonElement input, string name, bool fallback)
    {
        if (!input.TryGetProperty(name, out var value)) return fallback;
        if (value.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
            throw new InvalidOperationException("include_whitespace_invalid");
        return value.GetBoolean();
    }

    private static int OptionalInteger(JsonElement input, string name, int fallback, int minimum, int maximum)
    {
        if (!input.TryGetProperty(name, out var value)) return fallback;
        if (!value.TryGetInt32(out var number) || number < minimum || number > maximum)
            throw new InvalidOperationException("minimum_word_length_out_of_range");
        return number;
    }

    private static bool IsWhiteSpace(int scalar) => scalar is >= 0x0009 and <= 0x000D
        or 0x0020 or 0x0085 or 0x00A0 or 0x1680
        or >= 0x2000 and <= 0x200A or 0x2028 or 0x2029 or 0x202F or 0x205F or 0x3000;

References and pinned sources

Implementation excerpts identify exact source commits. Source snapshots can advance while the public SDK and host qualification remain incomplete; the page preserves the identity it was generated from. See all source pins and acceptance evidence.