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.
🔍 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.
# repository GitHub (ex sst/opencode, oggi sotto l'org anomalyco)
https://github.com/anomalyco/opencode
# sito e documentazione
https://opencode.ai/docs🧩 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.
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.
# 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🔌 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.
opencode
# dentro la TUI:
/connect # → Anthropic → Manually enter API Key
/connect # → GitHub Copilot → device flow
# modelli realmente disponibili per gli account collegati
/models📝 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.
opencode
# dentro la TUI:
/connect
# → OpenAI
# → ChatGPT Plus/Pro oppure Manually enter API Key
/models⚙️ 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.
{
"$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" }
}
}🦙 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.
# 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 32768Ollama provider · OpenCode docs ↗
🤖 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.
---
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.🖥️ 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`).
# 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"}]}'🌐 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.
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.
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🧾 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.
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);📡 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.
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);
}🧩 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.
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;
}
}🛠️ 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.
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);🚏 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.
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"); }
}
}▶️ 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.
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"]
}
} */🧪 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.
# 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📄 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 — 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<...>🧰 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.
---
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.⚠️ 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.
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 ↗
🪵 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.
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 ↗
🔑 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.
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;
}
}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.