OpenCode: the complete tutorial, from install to a .NET Minimal API gateway
One agent, many models: OpenCode lets me use Claude to write code, Codex to plan and free local Ollama to explore the codebase — from the same TUI. And since there's an HTTP server with an OpenAPI spec underneath, I can also hook it into my .NET applications. In this guide I do both, step by step.
🔍 What OpenCode is and why I care
OpenCode is an open source AI coding agent: an agent that reads your codebase, runs tools (grep, file edits, shell commands) and works from the terminal, the IDE or the desktop app. The project is maintained by the Anomaly team (the SST folks) and the official repository is `github.com/anomalyco/opencode`.
The difference from each vendor's own agent is simple: it's not tied to a single provider. With the same interface I connect Anthropic, OpenAI, GitHub Copilot, Google or a local model via Ollama — and I can assign a different model to each role (build, plan, explore).
What makes it interesting to me goes beyond the TUI: the client/server architecture makes it programmable. That's what I exploit in the second half of this tutorial.
- Open source (permissive license), 180k+ GitHub stars and frequent releases.
- 75+ supported providers: cloud (Claude, Codex, Copilot, Gemini) and local (Ollama, LM Studio).
- Per-role agents, custom subagents, plugins and a built-in HTTP server.
# GitHub repository (formerly sst/opencode, now under the anomalyco org)
https://github.com/anomalyco/opencode
# website and documentation
https://opencode.ai/docs🧩 The architecture: one local server, many clients
When I launch `opencode` in the terminal, it's not "one program" starting: it's two. A local HTTP server that holds all the logic (sessions, tools, providers) and a TUI that is just a client of that server.
This separation is the key to the whole tutorial: if the TUI is just another client, then my own application can be one too. The server publishes an OpenAPI 3.1 spec at `/doc`, so the contract is documented and I can write a typed client. The documentation does not promise API stability or versioning, though, so I re-check the spec before upgrading OpenCode.
All the logic lives in the local server: the TUI, the IDE and my Minimal API all talk to the same HTTP endpoint.
📦 Installing in two minutes
Installation is a one-liner. I use the official script, but npm and Homebrew work just as well (the `anomalyco/tap` tap is more up to date than the official brew formula).
On Windows the recommended route is WSL; Chocolatey and Scoop packages exist too.
# official script (macOS / Linux / WSL)
curl -fsSL https://opencode.ai/install | bash
# alternatively: npm
npm install -g opencode-ai
# or Homebrew (the team's recommended tap)
brew install anomalyco/tap/opencode
# verify
opencode --version🔌 Connecting providers with /connect
Providers are connected inside the TUI with `/connect`. For Anthropic, the clearest and most reproducible route for an integration is `Manually enter API Key`. The OpenCode page also mentions Claude Pro/Max, but warns that plugins reusing Claude subscriptions are prohibited by Anthropic, so I do not install community OAuth plugins here.
For GitHub Copilot, I select the provider and complete the device flow at `github.com/login/device`. Some models require Copilot Pro+; `/models` lists only the models available to my account.
Credentials stay on the machine. After each connection I run `/models`: the IDs returned by my installation are the source of truth for the config.
opencode
# inside the TUI:
/connect # → Anthropic → Manually enter API Key
/connect # → GitHub Copilot → device flow
# models actually available to the connected accounts
/models📝 Codex with ChatGPT: built-in authentication
OpenCode supports OpenAI authentication natively. Inside `/connect` I select OpenAI, then choose `ChatGPT Plus/Pro` for browser login or `Manually enter API Key` to use the API platform.
A ChatGPT subscription is appropriate for personal interactive use. For a shared gateway, internal service or production environment I use an API key and the organisation's policies instead: a personal account should not become a shared credential.
opencode
# inside the TUI:
/connect
# → OpenAI
# → ChatGPT Plus/Pro or Manually enter API Key
/models⚙️ The complete opencode.json
All configuration lives in `~/.config/opencode/opencode.json` (plus optional per-project overrides). Provider credentials stay in the ones created by `/connect`: the config file declares Ollama and one model per agent.
I use Claude for build, Codex for plan, local Qwen3.5 9B for explore and Copilot for general. For `small_model` I use Qwen3.5 4B. Both Ollama tags advertise tool support and a maximum 256K window; here I declare a lighter 32K operating budget.
The four names under `agent` are not on the same level: `build` and `plan` are the primary agents (in the TUI you switch between them with Tab), while `explore` and `general` are built-in subagents the primary delegates to automatically via the Task tool, based on their descriptions. That orchestration is what won me over: different phases of the work, different agents and models — and I exploit it from the gateway too, by passing `agent` in the request.
The cloud IDs are current examples, not a contract. If one is absent from `/models`, I replace it with the ID returned for the connected account.
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"name": "Ollama (local)",
"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" }
}
}🦙 The right Ollama models (small, not giant)
For explore and `small_model`, the main requirements are reliable tool calling and enough context. I use `qwen3.5:4b` and `qwen3.5:9b`: they are about 3.4 and 6.6 GB, and Ollama advertises tool support and a maximum 256K window for both.
The server's effective context still depends on the Ollama runtime and available memory. OpenCode does not enforce a minimum threshold: its documentation suggests starting at 16K–32K when tool calls fail. This example configures 32K both in Ollama and in the model `limit` entries.
# lightweight small_model (~3.4 GB)
ollama pull qwen3.5:4b
# local explore model (~6.6 GB)
ollama pull qwen3.5:9b
# runtime budget also declared in opencode.json
export OLLAMA_CONTEXT_LENGTH=32768
# macOS app: set the variable, then restart Ollama
launchctl setenv OLLAMA_CONTEXT_LENGTH 32768Ollama provider · OpenCode docs ↗
🤖 A custom subagent: the code reviewer
Besides the primary agents I can define subagents in markdown files under `~/.config/opencode/agents/` (or `.opencode/agents/` in the project). A subagent has its own model, temperature and permissions.
This reviewer runs on Copilot, cannot edit files (`edit: deny`) and asks before every shell command. I can invoke it manually with `@reviewer`, but a primary agent may also select it automatically from its `description`. To prevent that, I configure `permission.task` on the primary agent.
---
description: Review the diff for bugs, missing tests and style drift
mode: subagent
model: github-copilot/gpt-5.2
temperature: 0.2
permission:
edit: deny
bash: ask
---
You are a code reviewer. Return a punch list by severity:
must-fix, should-fix, nice-to-have.🖥️ opencode serve: the agent becomes an HTTP API
And here's the turning point. `opencode serve` starts the headless server (default `127.0.0.1:4096`), the same one the TUI uses under the hood. The OpenAPI spec is served at `/doc`.
The minimal flow is two calls: create a session with `POST /session`, then send a message with `POST /session/{id}/message`. The response contains the assistant message parts: the ones with `type: "text"` are the actual answer.
On a local or shared network I always add basic auth: the `OPENCODE_SERVER_PASSWORD` variable is enough (the default username is `opencode`).
# start the headless server (in the project folder it should work on)
OPENCODE_SERVER_PASSWORD=supersecret opencode serve --port 4096
# 1) create a session
curl -s -u opencode:supersecret -X POST http://127.0.0.1:4096/session \
-H 'Content-Type: application/json' -d '{"title":"demo"}'
# → {"id":"ses_090d852eeffeul0zvfzfR2HT77", ...}
# 2) send a prompt to the session (synchronous: waits for the reply)
curl -s -u opencode:supersecret -X POST \
http://127.0.0.1:4096/session/ses_090d852eeffeul0zvfzfR2HT77/message \
-H 'Content-Type: application/json' \
-d '{"parts":[{"type":"text","text":"Explain this repository"}]}'🌐 The idea: a .NET gateway in front of OpenCode
Now the extra step: I wrap those two calls in a .NET 9 Minimal API, so an authorised internal client can ask the agent something with a single POST, without knowing about sessions and parts. The tutorial's API key is suitable for a single-tenant demo; in production I use OIDC/JWT or a trusted reverse proxy, TLS, rate limiting and audit logs.
The style is the one I use in all my backends: no Controllers, endpoints grouped with `MapGroup` in extension methods, `record` DTOs, `TypedResults` with explicit return types and Refit for the typed HTTP client.
The full flow is in the diagram below: my API never talks to the model vendors directly — OpenCode is the only integration point.
curl → Minimal API → Refit → opencode serve → provider. The answer comes back as clean JSON.
🧱 The project and the csproj
I explicitly create a .NET 9 project, add Refit and the OpenAPI package at reproducible versions, then initialise User Secrets. .NET 9 support ends on 10 November 2026: I keep the tutorial's target, but I would choose .NET 10 LTS for a new production service.
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" "supersecret"
dotnet user-secrets set "Gateway:ApiKey" "generate-a-long-random-value"
# final structure
# 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 and models: typed records only
Settings follow the Options pattern: `OpenCodeSettings` controls the upstream and `GatewaySettings` the API key and agent allowlist. The password and key come from User Secrets in development or a secret store/environment variables in deployment.
`SessionId` is optional: when absent I create a session; when the client sends a `ses_...` ID back, I continue the conversation. A multi-tenant gateway must also store and verify ownership of every session.
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"];
}
// ----- gateway contracts -----
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);
// ----- OpenCode server contracts (camelCase on the wire) -----
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);📡 The Refit client: OpenCode's API as an interface
With Refit, OpenCode's HTTP API becomes a C# interface: two methods, one to create the session and one to send the message. No hand-rolled `HttpClient`, no serialization boilerplate.
Why Refit and not a bare `HttpClient`? I dig into that in the three bonus sections at the end of the article: typing, DI with IHttpClientFactory and — above all — the `DelegatingHandler` pipeline for logging and JWT authentication.
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);
}🧩 The DI registration: AddOpenCode()
All the registration lives in one extension method. Here I validate configuration at startup, set the camelCase serializer, bound the timeout to 1–900 seconds and configure basic auth towards OpenCode. If a key or critical setting is missing, the gateway will not start in an unsafe state.
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;
}
}🛠️ The service: session + message + text extraction
The service creates a session only when `SessionId` is absent, sends the prompt and filters the text parts. It also translates Refit, network and timeout failures into gateway exceptions without returning upstream bodies to callers; client-requested cancellation still propagates.
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);🚏 The endpoint: MapGroup, TypedResults and explicit returns
The filter compares `X-API-Key` in constant time and protects the whole group. The handler validates the prompt, session format and agent allowlist; it maps a missing session to 404 and upstream failures to 502/503/504, so OpenAPI documents the real outcomes without leaking internals.
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 and appsettings.json
`AddOpenApi` and `MapOpenApi` actually expose the gateway schema at `/openapi/v1.json`; I can restrict the mapping to Development when production discovery is undesirable. `appsettings.json` contains only non-secret values: the password and API key stay in User Secrets or the deployment's secret store.
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"]
}
} */🧪 The final test: the curl that closes the loop
The sample was compiled and verified against a controlled upstream: missing auth → 401, disallowed agent → 400, new or reused session → 200, missing session → 404 and upstream rejection → 502. The curl below is the actual integration test with `opencode serve` and the configured provider, so reply text and timing will vary.
I keep OpenCode on loopback with least-privilege permissions because an allowed agent can run powerful tools. For long jobs I prefer `POST /session/{id}/prompt_async` with SSE or job status instead of holding a synchronous request open for minutes.
# terminal 1 — the OpenCode server
OPENCODE_SERVER_PASSWORD=supersecret opencode serve --port 4096
# terminal 2 — the .NET gateway
dotnet run --urls http://localhost:5099
# terminal 3 — the test
curl -s -X POST http://localhost:5099/api/v1/assistant \
-H 'Content-Type: application/json' \
-H 'X-API-Key: generate-a-long-random-value' \
-d '{"prompt":"Say hello","agent":"plan"}'
# response shape; text and ID depend on the live run
# → {"sessionId":"ses_...","reply":"..."}
# send sessionId in the next body to continue the same conversation📄 CLAUDE.md in the project: rules for the agents
I evolve the gateway with coding agents, so I give it a CLAUDE.md at the root right away: it's the file Claude Code loads at session start with stack, commands and conventions. OpenCode reads its own equivalent, `AGENTS.md` (generated with `/init`), so I keep the contents aligned.
Few lines, but targeted: the conventions an agent would get wrong by default (Controllers instead of Minimal API, HttpClient instead of Refit) must be written down explicitly.
# CLAUDE.md — OpenCodeGateway
.NET 9 Minimal API gateway that calls OpenCode via `opencode serve`.
## Stack
- .NET 9 Minimal API — NEVER MVC Controllers
- Refit for HTTP clients — NEVER hand-rolled HttpClient
- DTOs: positional records — NEVER object/dynamic/anonymous types
- Settings: Options pattern with a SectionName constant
## Commands
- `dotnet build` — build
- `dotnet run --urls http://localhost:5099` — start on the tutorial's port
## Conventions
- Endpoints in `Endpoints/*.cs` as `MapXEndpoints` extensions over MapGroup
- DI registrations in `Extensions/ServiceCollectionExtensions.cs` (AddX)
- Handlers: TypedResults + explicit Results<...> return types🧰 A PROJECT-level skill: checking the contracts towards OpenCode
Claude Code skills are folders with a `SKILL.md`: they load only when relevant (based on the description). Mind the location: under `~/.claude/skills/` a skill is global (applies everywhere), under the project's `.claude/skills/` it's specific to that repository. The one I create here is deliberately project-level: it's an example of how to package knowledge that only makes sense in this codebase — which OpenCode endpoints I use, where my DTOs live — not a universal rule that belongs in a global skill.
And I picked a skill that's actually useful for this project: OpenCode evolves fast, and the gateway's concrete risk is contract drift — my Refit records no longer matching the server's API after an upgrade. The skill compares the DTOs in `Models/Models.cs` against the live OpenAPI spec that `opencode serve` publishes at `/doc` (real JSON, I verified it).
I invoke it with `/opencode-contract-check` (or "check the contracts towards opencode") after every OpenCode upgrade, or when calls start failing at deserialization.
---
name: opencode-contract-check
description: Verify the gateway's Refit DTOs (Models/Models.cs)
against the live OpenAPI spec of opencode serve. Use after an
OpenCode upgrade or when CreateSession/SendMessage start failing
at deserialization.
---
# Check contract drift towards opencode serve
1. Health check (basic auth if the server has a password):
`curl -s -u opencode:$OPENCODE_SERVER_PASSWORD http://127.0.0.1:4096/global/health`.
If the server is down, ask to start `opencode serve` and stop.
2. Fetch the OpenAPI 3.1 spec (JSON):
`curl -s -u opencode:$OPENCODE_SERVER_PASSWORD http://127.0.0.1:4096/doc`.
3. Extract the schemas for the only endpoints the gateway uses:
`POST /session` and `POST /session/{id}/message`.
4. Compare with the records in `Models/Models.cs`:
OpenCodeSession, SendMessageRequest, OpenCodeMessageResponse,
OpenCodePart (missing fields, renames, type changes).
5. Report the drift by severity (must-fix / nice-to-have) and propose
the record diffs — do NOT apply them without confirmation.
6. Note in the report the server version read from /global/health.⚠️ Mistakes to avoid
The pitfalls I hit myself (or came close to), so you can dodge them from the start.
- Mismatched Ollama context: `limit.context` must reflect the server's effective budget. Start at 16K–32K and raise it only when the model and available memory support it.
- Model IDs copied from a blog post (including this one!): the source of truth is `/models` inside the TUI after `/connect` — IDs change from release to release.
- Server exposed without auth: `opencode serve` binds to 127.0.0.1 by default; if you change the hostname, always set `OPENCODE_SERVER_PASSWORD`.
- Gateway without identity or session ownership: the shared API key is only suitable for this single-tenant demo; a multi-user service must authenticate each caller and associate every session with its owner.
- Default HTTP timeout: 100 seconds isn't enough for a real agent run; the gateway's Refit client must be raised to minutes.
- Expecting only text in the parts: the reply also includes tool and reasoning parts; filter by `type == "text"` like the service does.
- Giant local models "because bigger is better": for explore and small_model a 4–9B like Qwen3.5 is faster and sufficient; keep the big model for build, in the cloud.
✅ Final checklist
The full sequence, ready to tick off when you replicate it.
- OpenCode installed and `opencode --version` answers.
- Providers connected with `/connect`: OpenAI through built-in ChatGPT auth or an API key, Anthropic through an API key, and GitHub Copilot through the device flow.
- `opencode.json` with the Ollama provider, `model`, `small_model` and one model per agent (build/plan/explore/general).
- `qwen3.5:4b` and `qwen3.5:9b` pulled, with a consistent initial 32K budget in Ollama and `limit.context`.
- Subagent `reviewer.md` created and callable with `@reviewer`.
- `opencode serve` running with a password and test curls on `/session` and `/session/{id}/message` working.
- .NET gateway: secrets outside appsettings, `X-API-Key`, agent allowlist, real OpenAPI, clean build and a working live curl.
- CLAUDE.md at the project root and the project-level `opencode-contract-check` skill in `.claude/skills/` (not global: it lives in the repo).
🎁 Bonus: why Refit and not a bare HttpClient
I'll close with three bonus sections on the piece that keeps the whole gateway clean: Refit. The question I get asked most is "wouldn't an `HttpClient` have been enough?". It would, but I'd have written the boilerplate myself: hand-built URLs, `PostAsJsonAsync`, status code checks, deserialization, header juggling.
With Refit the local contract is a typed interface: the compiler verifies that my code uses its methods and DTOs correctly. If the remote API changes instead, I detect it by comparing the interface with the `/doc` spec through the contract-check skill; without that check, drift can still surface at runtime. And since registration goes through `AddRefitClient`, IHttpClientFactory works underneath: handler pooling and lifetimes managed by the framework, with a client injectable through DI.
The advantage I lean on the most, though, is the DelegatingHandler pipeline: logging, authentication and retries hook onto the client at registration time, without touching a single line of calling code. In the next two bonuses I show the two handlers I reuse most often in my real projects.
- Typing: requests and responses are C# records; the compiler catches incompatible uses in my code, while contract-check verifies the remote contract before deployment.
- DI and IHttpClientFactory: `AddRefitClient` registers the client in the container with proper pooling and lifetimes — no `new HttpClient()` scattered around.
- Centralized serialization: camelCase, null ignoring and converters configured once in `RefitSettings`.
- Cross-cutting via handlers: logs, JWT, retries and custom headers live in the HTTP pipeline, not inside services.
- Testability: in tests I mock `IOpenCodeApi` like any interface — zero fake HTTP to orchestrate.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Refit;
// Example contract: replace the route and methods with the real API contract.
public interface IProtectedApi
{
[Get("/api/profile")]
Task<HttpResponseMessage> GetProfileAsync(CancellationToken ct = default);
}
public static class ProtectedApiServiceCollectionExtensions
{
public static IServiceCollection AddProtectedApi(this IServiceCollection services)
{
// DelegatingHandlers are registered as transient...
services.AddTransient<JwtAuthHandler>();
services.AddTransient<HttpLoggingHandler>();
// ...and chained onto the Refit client.
// On requests, auth runs before logging; no headers or bodies are printed.
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: logging every call with a DelegatingHandler
A `DelegatingHandler` is middleware for the outgoing HTTP pipeline: it intercepts the request before it leaves and the response before it reaches the caller. This safe baseline correlates request and response with an id and measures duration with `Stopwatch`, without recording query strings, headers or bodies.
That is intentional: prompts, passwords, API keys and tokens can end up in payloads. If I need to inspect JSON during development, I enable a separate logger with structured redaction and a size limit; I never add raw bodies to this handler.
using System.Diagnostics;
public sealed class HttpLoggingHandler(ILogger<HttpLoggingHandler> logger) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
// Correlate request and response lines with a short id.
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: automatic JWT with an auth handler
Same mechanism, different use case: an API protected by JWT with an endpoint that issues the token (like the `POST /api/auth/signin` I use in my backends). Instead of handling the token in the services, I let a handler do it: before every call it asks a caching provider for the token and puts it in the `Authorization` header.
The provider is a singleton: it keeps the token until it is close to expiry and uses a `SemaphoreSlim` so that ten concurrent calls do not trigger ten logins. It does not capture a Refit typed client, which must remain short-lived: it keeps `IHttpClientFactory` and creates the `IAuthApi` proxy from a named client only during refresh. The `AuthApi` client has no auth handler, otherwise login would try to authenticate itself in a loop.
The username and password come from User Secrets during development and from environment variables or a secret store when deployed: I never put them in `appsettings.json` or logs.
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);
// Refit client for the endpoint that issues the JWT.
// Registered WITHOUT JwtAuthHandler: no recursive auth.
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>Returns a cached JWT, requesting a new one when missing or close to expiry.</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)
{
// Attach a valid bearer token to every outgoing call.
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 without JwtAuthHandler: avoids recursion during 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;
}
}Frequently asked questions about OpenCode tutorial
Is OpenCode free? What does it cost to use?
OpenCode itself is open source and free. You pay the chosen provider: for example a pay-as-you-go API key, ChatGPT Plus/Pro through built-in OpenAI auth, or a compatible GitHub Copilot plan. Local Ollama has no API charges, but the machine still has a cost.
How is OpenCode different from Claude Code or Codex CLI?
Claude Code and Codex CLI are tied to their own vendor; OpenCode is a universal client: one interface, 75+ providers, and a different model assignable to each role (build, plan, explore). On top of that it exposes an HTTP server documented with OpenAPI, designed for integrating it into your own applications.
Can I use OpenCode without sending code to the cloud?
Yes: with only Ollama configured, the model and tools run on your machine. Pick a model with reliable tool calling and a context budget that fits the available RAM; OpenCode suggests starting at 16K–32K. This example uses Qwen3.5 and 32K. Quality may still be below cloud models for complex tasks.
How do I call OpenCode from a .NET application?
Start opencode serve (HTTP on 127.0.0.1:4096, OpenAPI spec at /doc) and make two calls: POST /session to create the session and POST /session/{id}/message with the prompt's text parts. In the tutorial I wrap the flow in a .NET 9 Minimal API with a typed Refit client.
Let's talk
If this topic is relevant to you, write to me: comparing notes on code and AI is always time well spent.