The .NET library lives in dotnet/Codexcw and is published to NuGet as
C3OSS.Codexcw (namespace C3OSS.Codexcw) by the dotnet-v* release train.
dotnet add package C3OSS.CodexcwTo build against the repository directly, reference the project instead:
dotnet add reference path/to/codexcw/dotnet/Codexcw/Codexcw.csprojRunners drive Codex (the default), Claude Code, or Grok Build; the selected agent's
executable must be on PATH and authenticated — codex new enough to support
codex exec --json, claude new enough to support
--output-format stream-json, and grok new enough to support
streaming-messages-json. Codex
defaults are automation-friendly: read-only sandbox, approval never,
ephemeral sessions, color off, git-check skipped.
Every recipe works in either mode:
- Blocking —
runner.RunAsync(request)starts the process, drains its events, and returns the finalRunResult. - Streaming —
runner.Start(request)returns immediately with aSession; consumesession.Events()withawait foreachand callsession.WaitAsync()for the result.
using C3OSS.Codexcw;
var runner = new Runner();
// Blocking.
var result = await runner.RunAsync(new Request { Prompt = "diga oi" });
Console.WriteLine(result.FinalMessage);// Streaming: consume events live, then collect the result.
using var session = runner.Start(new Request { Prompt = "resuma este repo" });
await foreach (var evt in session.Events())
{
if (evt.ItemCompleted?.Item is { Kind: ItemKind.AgentMessage } item)
{
Console.WriteLine(item.Text);
}
}
var result = await session.WaitAsync();
Console.WriteLine($"usage: {result.Usage.TotalTokens}");The event stream is single-consumer, and waiting never depends on it: calling
WaitAsync without reading Events() is safe. Session and Group are
disposable: Dispose cancels anything still active and releases the run's
resources, and await using (DisposeAsync) additionally waits for the
internal tasks to finish. The recipes below use RunAsync for brevity; swap
in the Start pattern above to consume events live.
A RunOptions.Handler runs for every decoded event. Throwing cancels the run.
var result = await runner.RunAsync(new Request { Prompt = "trabalhe" }, new RunOptions
{
Handler = (evt, _) =>
{
switch (evt.Kind)
{
case EventKind.ItemCompleted when evt.ItemCompleted!.Item.Kind == ItemKind.CommandExecution:
Console.WriteLine($"$ {evt.ItemCompleted.Item.Command}");
break;
case EventKind.TurnCompleted:
Console.WriteLine($"tokens: {evt.TurnCompleted!.Usage.TotalTokens}");
break;
}
return ValueTask.CompletedTask;
},
});// A handler that aborts the run on the first command execution.
try
{
await runner.RunAsync(new Request { Prompt = "..." }, new RunOptions
{
Handler = (evt, _) =>
evt.ItemStarted?.Item.Kind == ItemKind.CommandExecution
? throw new InvalidOperationException("stop")
: ValueTask.CompletedTask,
});
}
catch (HandlerException ex)
{
Console.WriteLine($"cancelled by handler: {ex.InnerException?.Message}");
}Codex sessions are resumable by thread id, but only persisted ones: runs are
ephemeral by default (--ephemeral), so both the original run and the resume
need Persistent = true. Run once, capture result.ThreadId, then continue
the same thread with ResumeId.
var first = await runner.RunAsync(new Request
{
Prompt = "crie um arquivo TODO.md",
Persistent = true,
});
var threadId = first.ThreadId;
var second = await runner.RunAsync(new Request
{
Prompt = "agora adicione 3 itens ao TODO.md",
ResumeId = threadId,
Persistent = true,
});
Console.WriteLine(second.FinalMessage);// Resume the most recent persisted thread instead of tracking ids yourself.
await runner.RunAsync(new Request { Prompt = "continue", ResumeLast = true, Persistent = true });
// ResumeAll disables Codex's cwd filtering while resuming.
await runner.RunAsync(new Request
{
Prompt = "continue",
ResumeId = threadId,
ResumeAll = true,
Persistent = true,
});Resume runs do not accept
Dir,AddDirs, orProfile— setting them throwsInvalidRequestException.
// Read-only is the default. Let Codex write inside the workspace:
await runner.RunAsync(new Request
{
Prompt = "refatore o pacote foo",
Sandbox = SandboxMode.WorkspaceWrite,
});
// Remove sandbox filesystem restrictions entirely:
await runner.RunAsync(new Request
{
Prompt = "...",
Sandbox = SandboxMode.DangerFullAccess,
});// Defaults to ApprovalPolicy.Never (no prompts). The safer interactive middle ground:
await runner.RunAsync(new Request
{
Prompt = "...",
Sandbox = SandboxMode.WorkspaceWrite,
Approval = ApprovalPolicy.OnRequest,
});Danger.
DangerouslyBypassSandboxruns Codex with--dangerously-bypass-approvals-and-sandbox: no sandbox, no approval prompts. Only use this in a disposable, fully-trusted environment.
await runner.RunAsync(new Request
{
Prompt = "...",
DangerouslyBypassSandbox = true,
});
// Run enabled hooks without persisted trust:
await runner.RunAsync(new Request
{
Prompt = "...",
DangerouslyBypassHooks = true,
});using var group = runner.RunMany(
[
new Request { Prompt = "review package A" },
new Request { Prompt = "review package B" },
new Request { Prompt = "review package C" },
],
new GroupOptions { MaxConcurrent = 2 });
// Multiplexed events across all runs.
await foreach (var runEvent in group.Events())
{
Console.WriteLine($"[{runEvent.Index}] {runEvent.Event.Type}");
}
try
{
var results = await group.WaitAsync();
foreach (var result in results)
{
Console.WriteLine($"[{result.Index}] {result.Result!.FinalMessage}");
}
}
catch (GroupException ex) // thrown when any run failed; all results ride along
{
foreach (var result in ex.Results)
{
Console.WriteLine(result.Error is null
? $"[{result.Index}] {result.Result!.FinalMessage}"
: $"[{result.Index}] failed: {result.Error.Message}");
}
}Each ConfigOverride becomes a -c key=value argument.
await runner.RunAsync(new Request
{
Prompt = "...",
Config =
[
new ConfigOverride("model_reasoning_effort", "\"high\""),
new ConfigOverride("tools.web_search", "true"),
],
});Codex Fast mode uses the priority service tier.
await runner.RunAsync(new Request
{
Prompt = "...",
Config = [new ConfigOverride("service_tier", "\"priority\"")],
});Ask Codex to conform its final message to a JSON Schema, and write it to a file.
const string schema = """{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}""";
var result = await runner.RunAsync(new Request
{
Prompt = "resuma o repo como JSON",
OutputSchema = schema, // written to a temp file and passed as --output-schema
OutputLastMessagePath = "out.json",
});
Console.WriteLine(result.FinalMessage); // conforms to the schemaawait runner.RunAsync(new Request
{
Prompt = "...",
Dir = "/work/project",
AddDirs = ["/work/shared", "/work/vendor"],
});Model availability depends on how the codex CLI is authenticated (a ChatGPT
account exposes a different set than an API key); a request for an unavailable
model fails the run with a Codex error event.
await runner.RunAsync(new Request
{
Prompt = "...",
Model = "gpt-5.4-mini",
Profile = "work",
});The runner also wraps Claude Code's non-interactive mode
(claude -p --output-format stream-json). Select it with
RunnerOptions.Agent; the claude executable must be on PATH and
authenticated. Events are normalized into the same Event model —
thread.started carries the Claude session id, tool calls become
item.started/item.completed pairs, and the final result maps to
turn.completed — with Raw always keeping the original Claude JSON line.
var runner = new Runner(new RunnerOptions { Agent = Agent.Claude });
var result = await runner.RunAsync(new Request
{
Prompt = "crie um arquivo TODO.md",
Model = ClaudeModels.Haiku, // "haiku", "sonnet", or "opus"
PermissionMode = PermissionMode.AcceptEdits,
});
Console.WriteLine($"tokens: {result.Usage.TotalTokens}");
Console.WriteLine($"cost USD: {result.Usage.TotalCostUsd}");// Tool filters, structured output, and resume work per request:
await runner.RunAsync(new Request
{
Prompt = "rode os testes",
Model = ClaudeModels.Sonnet,
AllowedTools = ["Bash(dotnet test *)", "Read"],
DisallowedTools = ["WebSearch"],
});
var first = await runner.RunAsync(new Request { Prompt = "lembre disto", Persistent = true });
await runner.RunAsync(new Request
{
Prompt = "continue",
ResumeId = first.ThreadId, // or ResumeLast = true
Persistent = true,
});Claude runs support Dir (applied as the process working directory),
AddDirs, OutputSchema/OutputSchemaPath (passed as --json-schema), and
DangerouslyBypassSandbox (passed as --dangerously-skip-permissions).
PermissionMode, AllowedTools, and DisallowedTools are claude-only;
codex-only fields (Sandbox, Approval, Profile, Config, Images,
feature flags) throw InvalidRequestException on a claude runner.
The permission modes are AcceptEdits, Auto, BypassPermissions, Manual,
DontAsk, and Plan. Claude usage includes cache creation, total cost, and
per-model details in Usage.ModelUsage.
Claude account limits are available through the CLI's /usage report:
var accountUsage = await ClaudeAccount.GetClaudeAccountUsageAsync();
foreach (var window in accountUsage.Windows)
{
Console.WriteLine($"{window.Label}: {window.UsedPercent:0.#}% used, resets {window.ResetsAt}");
}ClaudeAccountUsage.Raw preserves the complete JSON result and Report
preserves Claude Code's human-readable response.
Select Grok Build with Agent.Grok. The wrapper invokes grok with
--no-auto-update, --output-format streaming-messages-json, --verbatim,
and a temporary --prompt-file that is removed after the process exits.
var grok = new Runner(new RunnerOptions { Agent = Agent.Grok });
var result = await grok.RunAsync(new Request
{
Prompt = "revise este repositório",
Dir = "/work/project",
Model = "grok-code-fast-1",
Profile = "reviewer", // passed as --agent
Sandbox = SandboxMode.WorkspaceWrite,
PermissionMode = PermissionMode.AcceptEdits,
AllowedTools = ["Bash(dotnet test *)"],
DisallowedTools = ["WebSearch"],
OutputSchema = """{"type":"object"}""",
});
Console.WriteLine(result.FinalMessage);New Grok runs default to the ReadOnly sandbox and DontAsk permission
mode. WorkspaceWrite maps to Grok's workspace profile and
DangerFullAccess maps to off. ApprovalPolicy.Never maps to dontAsk;
the other approval policies map to Grok's default mode. An explicit
PermissionMode is mutually exclusive with Approval.
DangerouslyBypassSandbox selects --sandbox off with
bypassPermissions.
ResumeId and ResumeLast map to --resume and --continue. A resume
request without an explicit Sandbox omits the flag so Grok restores the
session's saved sandbox. Grok always persists sessions, so Persistent does
not change its invocation. Grok does not expose an account-usage helper.
Request.Stdin is read and folded into the temporary prompt file during
Start, so Start can block on a slow input stream. Grok's Messages stream
reports reasoning text but no separate reasoning token count, so
ReasoningOutputTokens is zero. AddDirs, Images, Config, Enable,
Disable, StrictConfig, IgnoreUserConfig, IgnoreRules, RequireGitRepo,
OutputLastMessagePath, DangerouslyBypassHooks, and ResumeAll throw
InvalidRequestException because Grok has no equivalent option.
using System.Text;
// Prompt via stdin only:
await runner.RunAsync(new Request
{
Stdin = new MemoryStream(Encoding.UTF8.GetBytes("diga oi")),
});
// Prompt plus extra stdin context (wrapped in <stdin> markers):
await runner.RunAsync(new Request
{
Prompt = "resuma o diff abaixo",
Stdin = File.OpenRead("large.diff"),
});var runner = new Runner(new RunnerOptions
{
Executable = "/opt/codex/bin/codex",
Env = ["CODEX_HOME=/tmp/codex-home"],
});CodexAccount.GetAccountUsageAsync reads account limits and credits through
codex app-server. It accepts the same executable/env shape used by runners.
CODEX_HOME defaults to ~/.codex when it is not set. Timeout bounds each
JSON-RPC request; non-positive values use the 10-second default.
var usage = await CodexAccount.GetAccountUsageAsync(new AccountUsageRequest
{
Env = new Dictionary<string, string> { ["CODEX_HOME"] = "/tmp/codex-home" },
Timeout = TimeSpan.FromSeconds(5),
});
if (usage.Account is { } account)
{
Console.WriteLine($"account: {account.Email}");
}
if (usage.RateLimits.Primary is { } primary)
{
Console.WriteLine($"primary used: {primary.UsedPercent}");
}
if (usage.TokenUsage is { } tokens)
{
Console.WriteLine($"lifetime tokens: {tokens.Summary.LifetimeTokens}");
}Account and TokenUsage are null when codex answers those reads with a
JSON-RPC error; transport errors and timeouts fail the whole call.
Failures are typed exceptions rooted at CodexcwException, and every run
failure carries the partial report in Result. The one exception is
cancellation: RunCanceledException derives from
OperationCanceledException — so idiomatic cancellation handling keeps
working — and exposes the same Result property.
try
{
var result = await runner.RunAsync(new Request { Prompt = "..." });
}
catch (ExitException ex)
{
Console.WriteLine($"agent exited {ex.ExitCode}: {ex.Stderr}");
}
catch (CodexErrorException ex)
{
Console.WriteLine($"codex reported an error: {ex.Message}");
}
catch (ClaudeErrorException ex)
{
Console.WriteLine($"claude reported an error: {ex.Message}");
}
catch (GrokErrorException ex)
{
Console.WriteLine($"grok reported an error: {ex.Message}");
}
catch (DecodeException ex)
{
Console.WriteLine($"bad JSONL on line {ex.Line}");
}
catch (PromptRequiredException)
{
Console.WriteLine("prompt or stdin is required");
}
catch (CodexcwException ex)
{
Console.WriteLine($"error: {ex.Message} (events so far: {ex.Result?.Events.Count})");
}// Cancel a streaming session explicitly:
using var session = runner.Start(new Request { Prompt = "..." });
_ = Task.Delay(TimeSpan.FromSeconds(5)).ContinueWith(_ => session.Cancel());
await foreach (var _ in session.Events())
{
}
try
{
await session.WaitAsync();
}
catch (RunCanceledException ex) // derives OperationCanceledException
{
Console.WriteLine($"cancelled; events so far: {ex.Result?.Events.Count}");
}
// Or cancel through a CancellationToken:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await runner.RunAsync(new Request { Prompt = "..." }, cancellationToken: cts.Token);See the README for the cross-language overview and AGENTS.md for the project guide.