cool-solution — dev.blog
Tecnologie

OpenCode: tutorial completo, dall'installazione a un gateway .NET Minimal API

Un solo agente, tanti modelli: OpenCode mi permette di usare Claude per scrivere codice, Codex per pianificare e Ollama gratis per esplorare il codebase — dalla stessa TUI. E siccome sotto c'è un server HTTP con spec OpenAPI, posso agganciarlo anche dalle mie applicazioni .NET. In questa guida faccio entrambe le cose, passo passo.

Infografica in stile lavagna: OpenCode al centro tra i prompt e i provider Claude, Codex, Copilot e Ollama, con output consistente

🔍 Cos'è OpenCode e perché mi interessa

OpenCode è un AI coding agent open source: un agente che legge il codebase, esegue tool (grep, edit di file, comandi shell) e lavora dal terminale, dall'IDE o dall'app desktop. Il progetto è mantenuto dal team di Anomaly (gli stessi di SST) e il repository ufficiale è `github.com/anomalyco/opencode`.

La differenza rispetto agli agenti «di casa» dei singoli vendor è una: non è legato a un provider. Con la stessa interfaccia collego Anthropic, OpenAI, GitHub Copilot, Google o un modello locale via Ollama — e posso assegnare un modello diverso a ogni ruolo (build, plan, explore).

Il motivo per cui lo trovo interessante va oltre la TUI: l'architettura client/server lo rende programmabile. È questo che sfrutto nella seconda metà del tutorial.

  • Open source (licenza permissiva), oltre 180k stelle su GitHub e release frequenti.
  • 75+ provider supportati: cloud (Claude, Codex, Copilot, Gemini) e locali (Ollama, LM Studio).
  • Agenti configurabili per ruolo, subagent custom, plugin e server HTTP integrato.
Il repo ufficiale
# repository GitHub (ex sst/opencode, oggi sotto l'org anomalyco)
https://github.com/anomalyco/opencode

# sito e documentazione
https://opencode.ai/docs

anomalyco/opencode · GitHub

Il progetto è attivissimo: conviene tenere d'occhio le release perché gli ID dei modelli cambiano spesso.

🧩 L'architettura: un server locale, tanti client

Quando lancio `opencode` nel terminale non parte «un programma»: partono due cose. Un server HTTP locale che contiene tutta la logica (sessioni, tool, provider) e una TUI che è solo un client di quel server.

Questa separazione è la chiave di tutto il tutorial: se la TUI è un client come un altro, allora anche la mia applicazione può esserlo. Il server espone una spec OpenAPI 3.1 su `/doc`, quindi il contratto è documentato e posso scrivere un client tipizzato. La documentazione non promette però stabilità o versioning dell'API: prima di aggiornare OpenCode ricontrollo la spec.

Client/server: anche la TUI è solo un client
Diagramma: TUI, estensione IDE e una app .NET sono client del server locale opencode sulla porta 4096, che instrada verso Claude, Codex, Copilot e Ollama

Tutta la logica sta nel server locale: TUI, IDE e la mia Minimal API parlano tutti con lo stesso endpoint HTTP.

📦 Installazione in due minuti

L'installazione è un one-liner. Io uso lo script ufficiale, ma npm e Homebrew vanno benissimo (il tap `anomalyco/tap` è più aggiornato della formula ufficiale brew).

Su Windows la via consigliata è WSL; esistono comunque i pacchetti Chocolatey e Scoop.

Installazione
# script ufficiale (macOS / Linux / WSL)
curl -fsSL https://opencode.ai/install | bash

# in alternativa: npm
npm install -g opencode-ai

# oppure Homebrew (tap consigliato dal team)
brew install anomalyco/tap/opencode

# verifica
opencode --version

Intro · OpenCode docs

Tre vie equivalenti: script, npm o brew. Alla fine deve rispondere `opencode --version`.

🔌 Collegare i provider con /connect

I provider si collegano dentro la TUI con `/connect`. Per Anthropic, il percorso più chiaro e riproducibile per un'integrazione è `Manually enter API Key`. La pagina OpenCode cita anche Claude Pro/Max, ma avverte che i plugin che riusano l'abbonamento Claude sono vietati da Anthropic: qui non installo plugin OAuth community.

Per GitHub Copilot scelgo il provider omonimo e completo il device flow su `github.com/login/device`. Alcuni modelli richiedono Copilot Pro+; `/models` mostra solo quelli disponibili per il mio account.

Le credenziali restano sulla macchina. Dopo ogni collegamento uso `/models`: gli ID restituiti dalla mia installazione sono la fonte di verità per la configurazione.

Nella TUI
opencode

# dentro la TUI:
/connect   # → Anthropic → Manually enter API Key
/connect   # → GitHub Copilot → device flow

# modelli realmente disponibili per gli account collegati
/models

Providers · OpenCode docs

Un /connect per provider. Gli ID dei modelli li verifico sempre con /models: cambiano spesso.

📝 Codex con ChatGPT: autenticazione integrata

OpenCode supporta nativamente l'accesso OpenAI. Dentro `/connect` scelgo OpenAI, poi `ChatGPT Plus/Pro` per il login nel browser oppure `Manually enter API Key` per usare la piattaforma API.

L'abbonamento ChatGPT è adatto all'uso personale interattivo. Per un gateway condiviso, un servizio interno o un ambiente di produzione uso invece una API key e le relative policy organizzative: l'account personale non va trasformato in una credenziale condivisa.

Login OpenAI nativo
opencode

# dentro la TUI:
/connect
# → OpenAI
# → ChatGPT Plus/Pro oppure Manually enter API Key

/models

OpenAI · OpenCode docs

L'OAuth ChatGPT è integrato in OpenCode: non devo aggiungere plugin a opencode.json.

⚙️ Il file opencode.json completo

Tutta la configurazione vive in `~/.config/opencode/opencode.json` (più eventuali override per progetto). Le credenziali dei provider restano quelle create da `/connect`: nel file dichiaro Ollama e un modello per ogni agente.

Uso Claude per build, Codex per plan, Qwen3.5 9B locale per explore e Copilot per general. Per `small_model` scelgo Qwen3.5 4B. Entrambi i tag Ollama supportano i tool e pubblicano una finestra massima di 256K; qui dichiaro un budget operativo più contenuto: 32K.

I quattro nomi sotto `agent` non sono sullo stesso piano: `build` e `plan` sono gli agenti primari (nella TUI si passa dall'uno all'altro con Tab), mentre `explore` e `general` sono subagent integrati che il primario delega in automatico via Task tool in base alle loro description. È l'orchestrazione che mi ha convinto: fasi diverse del lavoro, agenti e modelli diversi — e la sfrutto anche dal gateway, passando `agent` nella richiesta.

Gli ID cloud sono esempi correnti, non un contratto. Se uno non compare nel mio `/models`, lo sostituisco con l'ID restituito dall'account collegato.

~/.config/opencode/opencode.json
{
  "$schema": "https://opencode.ai/config.json",
  "provider": {
    "ollama": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "Ollama (locale)",
      "options": { "baseURL": "http://localhost:11434/v1" },
      "models": {
        "qwen3.5:4b": {
          "name": "Qwen3.5 4B",
          "limit": { "context": 32768, "output": 8192 }
        },
        "qwen3.5:9b": {
          "name": "Qwen3.5 9B",
          "limit": { "context": 32768, "output": 8192 }
        }
      }
    }
  },

  "model": "anthropic/claude-sonnet-4-6",
  "small_model": "ollama/qwen3.5:4b",

  "agent": {
    "build":   { "model": "anthropic/claude-sonnet-4-6" },
    "plan":    { "model": "openai/gpt-5.2-codex" },
    "explore": { "model": "ollama/qwen3.5:9b" },
    "general": { "model": "github-copilot/gpt-5.2" }
  }
}

Config · OpenCode docs

Un modello per ruolo: il cloud dove serve qualità, il locale dove servono solo lettura file e grep.

🦙 I modelli Ollama giusti (piccoli, non giganti)

Per explore e `small_model` servono soprattutto tool calling affidabile e un context sufficiente. Uso `qwen3.5:4b` e `qwen3.5:9b`: occupano circa 3,4 e 6,6 GB e Ollama li pubblica con tool support e finestra massima di 256K.

La finestra effettiva del server Ollama dipende però dal runtime e dalla memoria disponibile. OpenCode non impone una soglia minima: la sua documentazione suggerisce di partire da 16K–32K se il tool calling fallisce. In questo esempio configuro 32K sia in Ollama sia nei `limit` del modello.

Modelli e context window
# small_model leggero (~3,4 GB)
ollama pull qwen3.5:4b

# modello locale per explore (~6,6 GB)
ollama pull qwen3.5:9b

# budget runtime usato anche in opencode.json
export OLLAMA_CONTEXT_LENGTH=32768

# macOS app: imposta la variabile e riavvia Ollama
launchctl setenv OLLAMA_CONTEXT_LENGTH 32768

Ollama provider · OpenCode docs

32K è un punto di partenza pratico, non un requisito assoluto. Aumentalo solo dopo aver verificato memoria, velocità e tool calling.

🤖 Un subagent custom: il code reviewer

Oltre agli agenti primari posso definire subagent in file markdown sotto `~/.config/opencode/agents/` (o `.opencode/agents/` nel progetto). Un subagent ha il suo modello, la sua temperatura e i suoi permessi.

Questo reviewer gira su Copilot, non può modificare file (`edit: deny`) e chiede conferma prima di ogni comando shell. Posso invocarlo a mano con `@reviewer`, ma un agente primario può anche selezionarlo automaticamente dalla sua `description`. Per impedirlo configuro `permission.task` sul primary agent.

~/.config/opencode/agents/reviewer.md
---
description: Review del diff per bug, test mancanti e style drift
mode: subagent
model: github-copilot/gpt-5.2
temperature: 0.2
permission:
  edit: deny
  bash: ask
---
Sei un code reviewer. Restituisci una punch list per severità:
must-fix, should-fix, nice-to-have.

Agents · OpenCode docs

Frontmatter YAML + prompt. Il model ID è un esempio: se non appare in /models, scelgo un modello Copilot disponibile per il mio piano.

🖥️ opencode serve: l'agente diventa un'API HTTP

Ed eccoci al punto di svolta. `opencode serve` avvia il server headless (default `127.0.0.1:4096`), lo stesso che la TUI usa sotto il cofano. La spec OpenAPI è servita su `/doc`.

Il flusso minimo è a due chiamate: creo una sessione con `POST /session`, poi mando un messaggio con `POST /session/{id}/message`. La risposta contiene le parts del messaggio dell'assistente: quelle con `type: "text"` sono la risposta vera e propria.

In rete locale o condivisa metto sempre la basic auth: basta la variabile `OPENCODE_SERVER_PASSWORD` (l'username di default è `opencode`).

Il server e le due curl fondamentali
# avvio il server headless (nella cartella del progetto su cui deve lavorare)
OPENCODE_SERVER_PASSWORD=segretissimo opencode serve --port 4096

# 1) creo una sessione
curl -s -u opencode:segretissimo -X POST http://127.0.0.1:4096/session \
  -H 'Content-Type: application/json' -d '{"title":"demo"}'
# → {"id":"ses_090d852eeffeul0zvfzfR2HT77", ...}

# 2) mando un prompt alla sessione (sincrono: aspetta la risposta)
curl -s -u opencode:segretissimo -X POST \
  http://127.0.0.1:4096/session/ses_090d852eeffeul0zvfzfR2HT77/message \
  -H 'Content-Type: application/json' \
  -d '{"parts":[{"type":"text","text":"Spiegami questo repository"}]}'

Server · OpenCode docs

Due POST e ho un agente via HTTP. L'id di sessione è quello restituito dalla prima chiamata.

🌐 L'idea: un gateway .NET davanti a OpenCode

Ora il passo in più: incapsulo quelle due chiamate in una Minimal API .NET 9, così un client interno autorizzato può interrogare l'agente con una singola POST, senza conoscere sessioni e parts. La API key del tutorial è adatta a un demo single-tenant: in produzione uso OIDC/JWT o un reverse proxy fidato, TLS, rate limiting e audit log.

Lo stile è quello che uso in tutti i miei backend: niente Controller, endpoint raggruppati con `MapGroup` in extension method, DTO `record`, `TypedResults` con tipi di ritorno espliciti e Refit per il client HTTP tipizzato.

Il flusso completo è nello schema qui sotto: la mia API non parla mai direttamente con i vendor dei modelli — l'unico punto di integrazione è OpenCode.

Il viaggio di una richiesta, in cinque passi
Sequenza in cinque passi: curl chiama la Minimal API .NET, il service usa Refit per creare la sessione e inviare il messaggio a opencode serve, che esegue l'agente sul provider configurato e restituisce la risposta JSON

curl → Minimal API → Refit → opencode serve → provider. La risposta torna indietro come JSON pulito.

🧱 Il progetto e il csproj

Creo esplicitamente un progetto .NET 9, aggiungo Refit e il pacchetto OpenAPI con versioni riproducibili, poi inizializzo User Secrets. .NET 9 termina il supporto il 10 novembre 2026: mantengo il target del tutorial, ma per un nuovo servizio di produzione sceglierei .NET 10 LTS.

Setup del progetto
dotnet new web -f net9.0 -n OpenCodeGateway && cd OpenCodeGateway
dotnet add package Refit.HttpClientFactory --version 13.1.0
dotnet add package Microsoft.AspNetCore.OpenApi --version 9.0.17
dotnet user-secrets init
dotnet user-secrets set "OpenCode:Password" "segretissimo"
dotnet user-secrets set "Gateway:ApiKey" "genera-un-valore-lungo-e-casuale"

# struttura finale
# OpenCodeGateway/
# ├── Program.cs
# ├── appsettings.json
# ├── Settings/OpenCodeSettings.cs
# ├── Models/Models.cs
# ├── Clients/IOpenCodeApi.cs
# ├── Services/AssistantService.cs + GatewayExceptions.cs
# ├── Security/GatewayApiKeyFilter.cs
# ├── Extensions/ServiceCollectionExtensions.cs
# └── Endpoints/AssistantEndpoints.cs
Framework, pacchetti e porta sono espliciti; password e API key restano fuori da appsettings.json.

🧾 Settings e modelli: solo record tipizzati

Le impostazioni seguono l'Options pattern: `OpenCodeSettings` governa l'upstream e `GatewaySettings` la API key e la allowlist degli agenti. Password e chiave arrivano da User Secrets in sviluppo o da un secret store/variabili d'ambiente in deploy.

`SessionId` è opzionale: se manca creo una nuova sessione; se il client rimanda un ID `ses_...`, continuo la conversazione. In un gateway multi-tenant devo anche registrare e verificare la proprietà di ogni sessione.

Settings/OpenCodeSettings.cs + Models/Models.cs
public sealed class OpenCodeSettings
{
    public const string SectionName = "OpenCode";

    public string BaseUrl { get; init; } = "http://127.0.0.1:4096";
    public string Username { get; init; } = "opencode";
    public string Password { get; init; } = string.Empty;
    public int TimeoutSeconds { get; init; } = 300;
}

public sealed class GatewaySettings
{
    public const string SectionName = "Gateway";
    public string ApiKey { get; init; } = string.Empty;
    public string[] AllowedAgents { get; init; } = ["build", "plan", "explore", "general"];
}

// ----- contratti del gateway -----
public record AskRequest(string Prompt, string? Agent = null, string? SessionId = null);
public record AskResponse(string SessionId, string Reply);
public record ApiErrorResponse(string Code, string Error);

// ----- contratti del server OpenCode (camelCase sul filo) -----
public record CreateSessionRequest(string? Title = null);
public record OpenCodeSession(string Id, string? Title = null);

public record OpenCodeMessagePart(string Type, string Text);
public record SendMessageRequest(IReadOnlyList<OpenCodeMessagePart> Parts, string? Agent = null);

public record OpenCodePart(string Type, string? Text = null);
public record OpenCodeMessageInfo(string Id, string Role);
public record OpenCodeMessageResponse(OpenCodeMessageInfo Info, IReadOnlyList<OpenCodePart> Parts);
DTO record per tutto, incluse le forme JSON del server OpenCode. Il compilatore lavora per me.

📡 Il client Refit: l'API di OpenCode in un'interfaccia

Con Refit l'API HTTP di OpenCode diventa un'interfaccia C#: due metodi, uno per creare la sessione e uno per mandare il messaggio. Niente `HttpClient` a mano, niente boilerplate di serializzazione.

Perché proprio Refit e non un `HttpClient` nudo? Lo approfondisco nelle tre sezioni bonus in fondo all'articolo: tipizzazione, DI con IHttpClientFactory e — soprattutto — la pipeline di `DelegatingHandler` per logging e autenticazione JWT.

Clients/IOpenCodeApi.cs
using Refit;

public interface IOpenCodeApi
{
    [Post("/session")]
    Task<OpenCodeSession> CreateSessionAsync(
        [Body] CreateSessionRequest request, CancellationToken ct = default);

    [Post("/session/{sessionId}/message")]
    Task<OpenCodeMessageResponse> SendMessageAsync(
        string sessionId, [Body] SendMessageRequest request, CancellationToken ct = default);
}

Refit · GitHub

L'intera integrazione HTTP sta in undici righe. Il resto lo genera Refit.

🧩 La registrazione DI: AddOpenCode()

Tutta la registrazione sta in un extension method. Qui valido la configurazione all'avvio, imposto il serializer camelCase, un timeout limitato a 1–900 secondi e la basic auth verso OpenCode. Se manca una chiave o un'impostazione critica, il gateway non parte con una configurazione insicura.

Extensions/ServiceCollectionExtensions.cs
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
using Refit;

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddOpenCode(
        this IServiceCollection services, IConfiguration configuration)
    {
        services.AddOptions<OpenCodeSettings>()
            .Bind(configuration.GetSection(OpenCodeSettings.SectionName))
            .Validate(s => Uri.TryCreate(s.BaseUrl, UriKind.Absolute, out _),
                "OpenCode:BaseUrl must be an absolute URL")
            .Validate(s => !string.IsNullOrWhiteSpace(s.Password),
                "OpenCode:Password is required")
            .Validate(s => s.TimeoutSeconds is > 0 and <= 900,
                "OpenCode:TimeoutSeconds must be between 1 and 900")
            .ValidateOnStart();

        services.AddOptions<GatewaySettings>()
            .Bind(configuration.GetSection(GatewaySettings.SectionName))
            .Validate(s => !string.IsNullOrWhiteSpace(s.ApiKey),
                "Gateway:ApiKey is required")
            .Validate(s => s.AllowedAgents.Length > 0,
                "Gateway:AllowedAgents cannot be empty")
            .ValidateOnStart();

        var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
        {
            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        };

        services
            .AddRefitClient<IOpenCodeApi>(
                new RefitSettings(new SystemTextJsonContentSerializer(serializerOptions)))
            .ConfigureHttpClient((provider, client) =>
            {
                var settings = provider.GetRequiredService<IOptions<OpenCodeSettings>>().Value;
                client.BaseAddress = new Uri(settings.BaseUrl);
                client.Timeout = TimeSpan.FromSeconds(settings.TimeoutSeconds);
                var credentials = Convert.ToBase64String(
                    Encoding.UTF8.GetBytes($"{settings.Username}:{settings.Password}"));
                client.DefaultRequestHeaders.Authorization =
                    new AuthenticationHeaderValue("Basic", credentials);
            });

        services.AddScoped<IAssistantService, AssistantService>();
        return services;
    }
}
ValidateOnStart blocca password, chiavi o limiti mancanti prima che il gateway accetti traffico.

🛠️ Il service: sessione + messaggio + estrazione del testo

Il service crea una sessione solo quando `SessionId` manca, invia il prompt e filtra le parts testuali. Traduce inoltre gli errori Refit, rete e timeout in eccezioni del gateway senza restituire al chiamante il body dell'upstream; la cancellazione richiesta dal client continua invece a propagarsi.

Services/AssistantService.cs
using System.Net;
using Refit;

public interface IAssistantService
{
    Task<AskResponse> AskAsync(AskRequest request, CancellationToken ct);
}

public sealed class AssistantService(
    IOpenCodeApi openCode,
    ILogger<AssistantService> logger) : IAssistantService
{
    public async Task<AskResponse> AskAsync(AskRequest request, CancellationToken ct)
    {
        try
        {
            var sessionId = request.SessionId
                ?? (await openCode.CreateSessionAsync(
                    new CreateSessionRequest("gateway"), ct)).Id;

            var message = new SendMessageRequest(
                [new OpenCodeMessagePart("text", request.Prompt)],
                request.Agent);

            var response = await openCode.SendMessageAsync(sessionId, message, ct);
            var reply = string.Join("\n", response.Parts
                .Where(p => p.Type == "text" && !string.IsNullOrWhiteSpace(p.Text))
                .Select(p => p.Text));
            return new AskResponse(sessionId, reply);
        }
        catch (ApiException ex) when (request.SessionId is not null &&
            ex.StatusCode == HttpStatusCode.NotFound)
        {
            throw new SessionNotFoundException(ex);
        }
        catch (ApiException ex)
        {
            logger.LogWarning(ex, "OpenCode returned HTTP {StatusCode}", (int)ex.StatusCode);
            throw new OpenCodeRejectedException(ex);
        }
        catch (ApiRequestException ex) when (ex.InnerException is TaskCanceledException &&
            !ct.IsCancellationRequested)
        {
            // Refit 13 wraps the HttpClient timeout: inner TaskCanceledException
            throw new OpenCodeTimeoutException(ex);
        }
        catch (ApiRequestException ex)
        {
            // Refit 13 wraps connection failures (server down, DNS, refused)
            throw new OpenCodeUnavailableException(ex);
        }
        catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
        {
            throw new OpenCodeTimeoutException(ex);
        }
    }
}

// Services/GatewayExceptions.cs
public sealed class SessionNotFoundException(Exception inner) : Exception("Session not found", inner);
public sealed class OpenCodeTimeoutException(Exception inner) : Exception("OpenCode timeout", inner);
public sealed class OpenCodeUnavailableException(Exception inner) : Exception("OpenCode unavailable", inner);
public sealed class OpenCodeRejectedException(Exception inner) : Exception("OpenCode rejected request", inner);
Sessione nuova o riusata, testo estratto e failure upstream tradotti senza perdere la cancellazione del client.

🚏 L'endpoint: MapGroup, TypedResults e ritorni espliciti

Il filtro confronta `X-API-Key` in tempo costante e protegge tutto il gruppo. L'handler valida prompt, formato della sessione e allowlist degli agenti; mappa sessione assente a 404 e problemi upstream a 502/503/504, così OpenAPI documenta i casi reali senza far trapelare dettagli interni.

Security/GatewayApiKeyFilter.cs + Endpoints/AssistantEndpoints.cs
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Extensions.Options;

public sealed class GatewayApiKeyFilter(IOptions<GatewaySettings> options) : IEndpointFilter
{
    public const string HeaderName = "X-API-Key";

    public ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context, EndpointFilterDelegate next)
    {
        var supplied = context.HttpContext.Request.Headers[HeaderName].ToString();
        var suppliedHash = SHA256.HashData(Encoding.UTF8.GetBytes(supplied));
        var expectedHash = SHA256.HashData(Encoding.UTF8.GetBytes(options.Value.ApiKey));
        if (!CryptographicOperations.FixedTimeEquals(suppliedHash, expectedHash))
            return ValueTask.FromResult<object?>(TypedResults.Unauthorized());
        return next(context);
    }
}

// Endpoints/AssistantEndpoints.cs
public static class AssistantEndpoints
{
    public static void MapAssistantEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/v1/assistant")
            .WithTags("Assistant")
            .AddEndpointFilter<GatewayApiKeyFilter>();

        group.MapPost("/", AskAsync).WithName("AskAssistant")
            .Produces(StatusCodes.Status401Unauthorized)
            .ProducesProblem(StatusCodes.Status502BadGateway)
            .ProducesProblem(StatusCodes.Status503ServiceUnavailable)
            .ProducesProblem(StatusCodes.Status504GatewayTimeout);
    }

    private static async Task<Results<Ok<AskResponse>, BadRequest<ApiErrorResponse>,
        NotFound<ApiErrorResponse>, ProblemHttpResult>> AskAsync(
        AskRequest request, IAssistantService assistant,
        IOptions<GatewaySettings> gateway, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(request.Prompt))
            return TypedResults.BadRequest(
                new ApiErrorResponse("invalid_prompt", "Prompt is required"));
        if (request.SessionId is not null &&
            !request.SessionId.StartsWith("ses_", StringComparison.Ordinal))
            return TypedResults.BadRequest(
                new ApiErrorResponse("invalid_session", "SessionId must start with 'ses_'"));
        if (request.Agent is not null &&
            !gateway.Value.AllowedAgents.Contains(request.Agent, StringComparer.Ordinal))
            return TypedResults.BadRequest(
                new ApiErrorResponse("invalid_agent", "Agent is not allowed"));

        try { return TypedResults.Ok(await assistant.AskAsync(request, ct)); }
        catch (SessionNotFoundException) { return TypedResults.NotFound(
            new ApiErrorResponse("session_not_found", "OpenCode session not found")); }
        catch (OpenCodeTimeoutException) { return TypedResults.Problem(
            statusCode: StatusCodes.Status504GatewayTimeout, title: "OpenCode timed out"); }
        catch (OpenCodeUnavailableException) { return TypedResults.Problem(
            statusCode: StatusCodes.Status503ServiceUnavailable, title: "OpenCode is unavailable"); }
        catch (OpenCodeRejectedException) { return TypedResults.Problem(
            statusCode: StatusCodes.Status502BadGateway, title: "OpenCode rejected the request"); }
    }
}
La API key è una protezione demo single-tenant; un servizio pubblico richiede identità, autorizzazioni e ownership delle sessioni.

▶️ Program.cs e appsettings.json

`AddOpenApi` e `MapOpenApi` espongono davvero lo schema del gateway su `/openapi/v1.json`; posso limitare la mappatura all'ambiente Development se non voglio discovery in produzione. In `appsettings.json` metto solo valori non segreti: password e API key restano in User Secrets o nel secret store del deploy.

Program.cs + appsettings.json
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();
builder.Services.AddOpenCode(builder.Configuration);

var app = builder.Build();

app.MapOpenApi(); // GET /openapi/v1.json
app.MapAssistantEndpoints();

app.Run();

/* appsettings.json:
{
  "OpenCode": {
    "BaseUrl": "http://127.0.0.1:4096",
    "Username": "opencode",
    "TimeoutSeconds": 300
  },
  "Gateway": {
    "AllowedAgents": ["build", "plan", "explore", "general"]
  }
} */
Lo schema del gateway è reale e i segreti non finiscono nel file versionato.

🧪 La prova finale: la curl che chiude il cerchio

Il sample è stato compilato e verificato con un upstream controllato: auth mancante → 401, agente vietato → 400, sessione nuova o riusata → 200, sessione assente → 404 e rifiuto upstream → 502. La curl qui sotto è invece il vero test d'integrazione con `opencode serve` e il provider configurato: testo e tempi della risposta varieranno.

Tengo OpenCode in loopback e con permessi minimi, perché un agente consentito può eseguire tool potenti. Per job lunghi preferisco `POST /session/{id}/prompt_async` con SSE o stato del job invece di bloccare una richiesta sincrona per minuti.

Test end-to-end
# terminale 1 — il server OpenCode
OPENCODE_SERVER_PASSWORD=segretissimo opencode serve --port 4096

# terminale 2 — il gateway .NET
dotnet run --urls http://localhost:5099

# terminale 3 — la prova
curl -s -X POST http://localhost:5099/api/v1/assistant \
  -H 'Content-Type: application/json' \
  -H 'X-API-Key: genera-un-valore-lungo-e-casuale' \
  -d '{"prompt":"Say hello","agent":"plan"}'

# forma della risposta; testo e ID dipendono dall'esecuzione live
# → {"sessionId":"ses_...","reply":"..."}
# rimanda sessionId nel body per continuare la stessa conversazione
Il test live richiede credenziali provider valide e può consumare quota; sessionId è riusabile nello stesso gateway single-tenant.

📄 CLAUDE.md nel progetto: le regole per gli agenti

Il gateway lo faccio evolvere con gli agenti di coding, quindi gli do subito un CLAUDE.md alla radice: è il file che Claude Code carica a inizio sessione con stack, comandi e convenzioni. OpenCode legge il suo equivalente `AGENTS.md` (generato con `/init`), quindi tengo i contenuti allineati.

Poche righe, ma mirate: le convenzioni che un agente sbaglierebbe di default (Controller invece di Minimal API, HttpClient invece di Refit) vanno scritte esplicitamente.

CLAUDE.md
# CLAUDE.md — OpenCodeGateway

Gateway .NET 9 Minimal API che invoca OpenCode via `opencode serve`.

## Stack
- .NET 9 Minimal API — MAI Controller MVC
- Refit per i client HTTP — MAI HttpClient a mano
- DTO: record posizionali — MAI object/dynamic/tipi anonimi
- Settings: Options pattern con costante SectionName

## Comandi
- `dotnet build` — build
- `dotnet run --urls http://localhost:5099` — avvio sulla porta del tutorial

## Convenzioni
- Endpoint in `Endpoints/*.cs` come extension `MapXEndpoints` su MapGroup
- Registrazioni DI in `Extensions/ServiceCollectionExtensions.cs` (AddX)
- Handler: TypedResults + tipo di ritorno esplicito Results<...>

Memory · Claude Code docs

Il «cervello» del progetto: caricato a ogni sessione, evita di ripetere le stesse istruzioni.

🧰 Una skill DI PROGETTO: il check dei contratti verso OpenCode

Le skill di Claude Code sono cartelle con un `SKILL.md`: si caricano solo quando servono (in base alla description). Attenzione alla posizione: sotto `~/.claude/skills/` una skill è globale (vale ovunque), sotto `.claude/skills/` del progetto è specifica di quel repository. Quella che creo qui è volutamente di progetto: è un esempio di come impacchettare conoscenza che ha senso solo in questo codebase — quali endpoint di OpenCode uso, dove vivono i miei DTO — non una regola universale da skill globale.

E ho scelto una skill davvero utile per questo progetto: OpenCode evolve in fretta, e il rischio concreto del gateway è il contract drift — i miei record Refit che non combaciano più con l'API del server dopo un upgrade. La skill confronta i DTO in `Models/Models.cs` con la spec OpenAPI live che `opencode serve` pubblica su `/doc` (JSON reale, l'ho verificato).

La invoco con `/opencode-contract-check` (o «controlla i contratti verso opencode») dopo ogni upgrade di OpenCode, o quando le chiamate iniziano a fallire in deserializzazione.

.claude/skills/opencode-contract-check/SKILL.md
---
name: opencode-contract-check
description: Verifica i DTO Refit del gateway (Models/Models.cs)
  contro la spec OpenAPI live di opencode serve. Usare dopo un
  upgrade di OpenCode o quando CreateSession/SendMessage falliscono
  in deserializzazione.
---

# Check del contract drift verso opencode serve

1. Health check (basic auth se il server ha la password):
   `curl -s -u opencode:$OPENCODE_SERVER_PASSWORD http://127.0.0.1:4096/global/health`.
   Se il server è giù, chiedi di avviare `opencode serve` e fermati.
2. Scarica la spec OpenAPI 3.1 (JSON):
   `curl -s -u opencode:$OPENCODE_SERVER_PASSWORD http://127.0.0.1:4096/doc`.
3. Estrai gli schema dei soli endpoint che il gateway usa:
   `POST /session` e `POST /session/{id}/message`.
4. Confronta con i record in `Models/Models.cs`:
   OpenCodeSession, SendMessageRequest, OpenCodeMessageResponse,
   OpenCodePart (campi mancanti, rinominati, tipi cambiati).
5. Riporta il drift per severità (must-fix / nice-to-have) e proponi
   i diff sui record — NON applicarli senza conferma.
6. Annota nel report la versione del server letta da /global/health.

Skills · Claude Code docs

Skill di progetto, non globale: conosce i MIEI file e i MIEI endpoint. Il drift si scopre prima che rompa in produzione.

⚠️ Errori da evitare

Gli inciampi in cui sono passato io (o ci sono andato vicino), così li eviti in partenza.

  • Context Ollama incoerente: `limit.context` deve riflettere il budget effettivo del server. Parti da 16K–32K e aumentalo solo se memoria e modello lo consentono.
  • ID modello copiati da un blog (incluso questo!): la fonte di verità è `/models` dentro la TUI dopo il `/connect` — gli ID cambiano di release in release.
  • Server esposto senza auth: `opencode serve` di default ascolta solo su 127.0.0.1; se cambi hostname, imposta sempre `OPENCODE_SERVER_PASSWORD`.
  • Gateway senza identità o ownership delle sessioni: la API key condivisa va bene solo per il demo single-tenant; un servizio multi-utente deve autenticare ogni caller e associare ogni sessione al suo proprietario.
  • Timeout HTTP di default: 100 secondi non bastano per un agent run vero; nel gateway il client Refit va portato a minuti.
  • Aspettarsi solo testo nelle parts: la risposta include anche parts di tool e reasoning; filtra per `type == "text"` come fa il service.
  • Modelli locali giganti «perché più è grosso meglio è»: per explore e small_model un 4–9B come Qwen3.5 è più veloce e sufficiente; il modello grande tienilo per build, in cloud.

✅ Checklist finale

La sequenza completa, buona da spuntare quando la replichi.

  • OpenCode installato e `opencode --version` risponde.
  • Provider collegati con `/connect`: OpenAI tramite auth ChatGPT integrata o API key, Anthropic tramite API key e GitHub Copilot tramite device flow.
  • `opencode.json` con provider Ollama, `model`, `small_model` e un modello per agente (build/plan/explore/general).
  • `qwen3.5:4b` e `qwen3.5:9b` scaricati, con un budget iniziale coerente di 32K in Ollama e in `limit.context`.
  • Subagent `reviewer.md` creato e invocabile con `@reviewer`.
  • `opencode serve` attivo con password e curl di prova su `/session` e `/session/{id}/message` funzionanti.
  • Gateway .NET: segreti fuori da appsettings, `X-API-Key`, allowlist agenti, OpenAPI reale, build pulita e curl live che risponde.
  • CLAUDE.md alla radice del progetto e skill di progetto `opencode-contract-check` in `.claude/skills/` (non globale: vive nel repo).

🎁 Bonus: perché Refit e non un HttpClient nudo

Chiudo con tre sezioni bonus sul pezzo che tiene pulito tutto il gateway: Refit. La domanda che mi sento fare spesso è «non bastava un `HttpClient`?». Bastava, ma il boilerplate lo avrei scritto io: URL composti a mano, `PostAsJsonAsync`, controlli di status code, deserializzazione, gestione degli header.

Con Refit il contratto locale è un'interfaccia tipizzata: il compilatore verifica che il mio codice usi correttamente metodi e DTO. Se cambia invece l'API remota, lo rilevo confrontando l'interfaccia con la spec `/doc` tramite la skill di contract-check; senza quel controllo, il drift può ancora emergere a runtime. E siccome la registrazione passa da `AddRefitClient`, sotto lavora IHttpClientFactory: pooling e lifetime degli handler gestiti dal framework, client iniettabile via DI.

Il vantaggio che sfrutto di più, però, è la pipeline di DelegatingHandler: logging, autenticazione e retry si agganciano al client in registrazione, senza toccare una riga del codice chiamante. Nei due bonus successivi mostro i due handler che riuso più spesso nei miei progetti reali.

  • Tipizzazione: request e response sono record C#; il compilatore intercetta gli usi incompatibili nel mio codice, mentre il contract-check verifica il contratto remoto prima del deploy.
  • DI e IHttpClientFactory: `AddRefitClient` registra il client nel container con pooling e lifetime corretti — mai `new HttpClient()` sparsi per il codice.
  • Serializzazione centralizzata: camelCase, ignore dei null e converter configurati una volta sola nella `RefitSettings`.
  • Cross-cutting via handler: log, JWT, retry e header custom vivono nella pipeline HTTP, non dentro i service.
  • Testabilità: nei test mocko `IOpenCodeApi` come qualunque interfaccia — zero HTTP finto da orchestrare.
La pipeline: handler agganciati in registrazione
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Refit;

// Contratto di esempio: sostituisco rotta e metodi con quelli dell'API reale.
public interface IProtectedApi
{
    [Get("/api/profile")]
    Task<HttpResponseMessage> GetProfileAsync(CancellationToken ct = default);
}

public static class ProtectedApiServiceCollectionExtensions
{
    public static IServiceCollection AddProtectedApi(this IServiceCollection services)
    {
        // I DelegatingHandler si registrano transient...
        services.AddTransient<JwtAuthHandler>();
        services.AddTransient<HttpLoggingHandler>();

        // ...e si agganciano al client Refit in catena.
        // In richiesta l'auth gira prima del logger; nessun header o body viene stampato.
        services
            .AddRefitClient<IProtectedApi>()
            .ConfigureHttpClient((provider, client) =>
            {
                var settings = provider.GetRequiredService<IOptions<AuthSettings>>().Value;
                client.BaseAddress = new Uri(settings.BaseUrl);
            })
            .AddHttpMessageHandler<JwtAuthHandler>()
            .AddHttpMessageHandler<HttpLoggingHandler>();

        return services;
    }
}

IHttpClientFactory · Microsoft Learn

IProtectedApi è un contratto di esempio; AuthSettings e i due handler sono definiti nei bonus successivi.

🪵 Bonus: loggare ogni chiamata con un DelegatingHandler

Un `DelegatingHandler` è un middleware della pipeline HTTP in uscita: intercetta la richiesta prima che parta e la risposta prima che torni al chiamante. Questa baseline sicura correla richiesta e risposta con un id e misura la durata con `Stopwatch`, senza registrare query string, header o body.

È una scelta intenzionale: prompt, password, API key e token possono finire nei payload. Se in sviluppo mi serve ispezionare il JSON, abilito un logger separato con redazione strutturata e un limite di dimensione; non aggiungo mai il body grezzo a questo handler.

Handlers/HttpLoggingHandler.cs
using System.Diagnostics;

public sealed class HttpLoggingHandler(ILogger<HttpLoggingHandler> logger) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        // Correla richiesta e risposta con un id corto.
        var id = Guid.NewGuid().ToString("N")[..8];
        var target = SafeTarget(request.RequestUri);
        var stopwatch = Stopwatch.StartNew();

        logger.LogInformation("[{Id} req] {Method} {Target}", id, request.Method, target);
        try
        {
            var response = await base.SendAsync(request, cancellationToken);
            logger.LogInformation(
                "[{Id} res] {StatusCode} in {ElapsedMs} ms",
                id, (int)response.StatusCode, stopwatch.ElapsedMilliseconds);
            return response;
        }
        catch (Exception exception)
        {
            logger.LogWarning(exception,
                "[{Id} err] {Method} {Target} after {ElapsedMs} ms",
                id, request.Method, target, stopwatch.ElapsedMilliseconds);
            throw;
        }
    }

    private static string SafeTarget(Uri? uri)
    {
        if (uri is null)
            return "(unknown)";

        var value = uri.IsAbsoluteUri ? uri.GetLeftPart(UriPartial.Path) : uri.OriginalString;
        var queryIndex = value.IndexOf('?');
        return queryIndex >= 0 ? value[..queryIndex] : value;
    }
}

HTTP client logging · Microsoft Learn

Baseline sicura: metodo, percorso senza query, status e durata. Header e body restano fuori dai log.

🔑 Bonus: JWT automatico con un auth handler

Stesso meccanismo, caso d'uso diverso: un'API protetta da JWT con un endpoint che rilascia il token (come il `POST /api/auth/signin` che uso nei miei backend). Invece di gestire il token nei service, lo faccio fare a un handler: prima di ogni chiamata chiede il token a un provider con cache e lo mette nell'header `Authorization`.

Il provider è un singleton: tiene il token finché non è vicino alla scadenza e usa un `SemaphoreSlim` per evitare che dieci chiamate concorrenti facciano dieci login. Non cattura però un typed client Refit, che deve restare short-lived: conserva `IHttpClientFactory` e crea il proxy `IAuthApi` dal named client solo durante il refresh. Il client `AuthApi` non ha l'auth handler, altrimenti il login proverebbe ad autenticare sé stesso in loop.

Username e password arrivano da User Secrets in sviluppo e da variabili d'ambiente o un secret store nel deploy: non li metto in `appsettings.json` né nei log.

Auth/JwtAuth.cs
using System.Net.Http.Headers;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Refit;

public sealed class AuthSettings
{
    public const string SectionName = "Auth";
    public string BaseUrl { get; init; } = string.Empty;
    public string UserName { get; init; } = string.Empty;
    public string Password { get; init; } = string.Empty;
}

public record TokenRequest(string UserName, string Password);
public record TokenResponse(string Token, DateTimeOffset ExpiresAt);

// Client Refit dell'endpoint che rilascia il JWT.
// Registrato SENZA JwtAuthHandler: niente auth ricorsiva.
public interface IAuthApi
{
    [Post("/api/auth/signin")]
    Task<TokenResponse> SignInAsync([Body] TokenRequest request, CancellationToken ct = default);
}

public sealed class JwtTokenProvider(
    IHttpClientFactory httpClientFactory,
    IOptions<AuthSettings> options)
{
    private readonly SemaphoreSlim gate = new(1, 1);
    private string? token;
    private DateTimeOffset expiresAt;

    /// <summary>Restituisce un JWT in cache, richiedendone uno nuovo se mancante o in scadenza.</summary>
    public async Task<string> GetTokenAsync(CancellationToken ct)
    {
        await gate.WaitAsync(ct);
        try
        {
            if (token is null || DateTimeOffset.UtcNow >= expiresAt.AddMinutes(-1))
            {
                var settings = options.Value;
                var authApi = RestService.For<IAuthApi>(httpClientFactory.CreateClient("AuthApi"));
                var response = await authApi.SignInAsync(new TokenRequest(settings.UserName, settings.Password), ct);
                token = response.Token;
                expiresAt = response.ExpiresAt;
            }

            return token;
        }
        finally
        {
            gate.Release();
        }
    }
}

public sealed class JwtAuthHandler(JwtTokenProvider tokenProvider) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        // Aggancia un bearer token valido a ogni chiamata in uscita.
        var token = await tokenProvider.GetTokenAsync(cancellationToken);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

        return await base.SendAsync(request, cancellationToken);
    }
}

public static class AuthServiceCollectionExtensions
{
    public static IServiceCollection AddJwtAuthentication(
        this IServiceCollection services, IConfiguration configuration)
    {
        services.AddOptions<AuthSettings>()
            .Bind(configuration.GetSection(AuthSettings.SectionName))
            .Validate(s => Uri.TryCreate(s.BaseUrl, UriKind.Absolute, out _),
                "Auth:BaseUrl must be an absolute URL")
            .Validate(s => !string.IsNullOrWhiteSpace(s.UserName),
                "Auth:UserName is required")
            .Validate(s => !string.IsNullOrWhiteSpace(s.Password),
                "Auth:Password is required")
            .ValidateOnStart();

        // Named client senza JwtAuthHandler: evita la ricorsione durante il login.
        services.AddHttpClient("AuthApi", (provider, client) =>
        {
            var settings = provider.GetRequiredService<IOptions<AuthSettings>>().Value;
            client.BaseAddress = new Uri(settings.BaseUrl);
        });

        services.AddSingleton<JwtTokenProvider>();
        services.AddTransient<JwtAuthHandler>();
        return services;
    }
}

Bearer auth · Refit

Esempio completo: token in cache, refresh serializzato e named client di auth fuori dalla pipeline protetta. Le credenziali restano nel secret store.

Domande frequenti su OpenCode tutorial

OpenCode è gratuito? Quanto costa usarlo?

OpenCode in sé è open source e gratuito. Paghi il provider scelto: per esempio una API key a consumo, ChatGPT Plus/Pro tramite auth OpenAI integrata o un piano GitHub Copilot compatibile. Con Ollama in locale non ci sono costi API, ma restano i costi della macchina.

Che differenza c'è tra OpenCode e Claude Code o Codex CLI?

Claude Code e Codex CLI sono legati al proprio vendor; OpenCode è un client universale: stessa interfaccia e 75+ provider, con un modello diverso assegnabile a ogni ruolo (build, plan, explore). In più espone un server HTTP documentato con OpenAPI, pensato per integrarlo nelle proprie applicazioni.

Posso usare OpenCode senza mandare codice nel cloud?

Sì: configurando solo Ollama, modello e tool girano sulla tua macchina. Scegli un modello con tool calling affidabile e un context compatibile con la RAM disponibile; OpenCode suggerisce di partire da 16K–32K. In questo esempio uso Qwen3.5 e 32K. La qualità può restare inferiore ai modelli cloud nei task complessi.

Come invoco OpenCode da un'applicazione .NET?

Avvii opencode serve (HTTP su 127.0.0.1:4096, spec OpenAPI su /doc) e fai due chiamate: POST /session per creare la sessione e POST /session/{id}/message con le parts testuali del prompt. Nel tutorial incapsulo il flusso in una Minimal API .NET 9 con un client Refit tipizzato.

Parliamone

Se questo tema ti riguarda, scrivimi: confrontarsi su codice e AI è sempre tempo speso bene.

Altri articoli del blog

limiti-token-codex-claude-code-2026.md14 luglio 2026function-calling-microsoft-extensions-ai-dotnet.md13 luglio 2026tips-claude-resume-luglio-2026.md10 luglio 2026