Extend
Write your own script executor
Where does an approved skill script run? By default, in a local process on the
Tracon host, under the host’s own operating-system identity. Register an
ISkillScriptExecutor and the script runs where your executor runs it instead:
a container, a micro-VM, or a runner service you operate. Tracon still decides
whether a script runs; your executor decides where.
What stays with Tracon
Section titled “What stays with Tracon”Turning scripts on is unchanged: UseSkillScripts, the acknowledgement flag, the
interpreter allow-list, and a tenant grant pinned to the content. An executor
receives a request only after every gate passed, and it cannot skip one:
flowchart LR
accTitle: What Tracon does around a script executor
accDescr: A script call passes Tracon's gates and the audit write, takes a concurrency slot, and only then reaches the executor. Tracon waits for the executor at most the script timeout plus five seconds, then caps the output again and records the call. A closed gate or a refused audit write never reaches the executor.
GATES["Gates: enabled · grant and content pin ·<br/>interpreter allow-list · arguments ·<br/>stored name · file size"] --> AUDIT["script.run written<br/>to the audit trail"]
AUDIT --> SLOT["Concurrency slot<br/>taken and held"]
SLOT --> EXEC["Your executor<br/>(waited for at most<br/>Timeout + 5 s)"]
EXEC --> AFTER["Output capped again ·<br/>span · metric ·<br/>tool call record"]
GATES -. closed .-> DENY["script.denied<br/>executor not called"]
AUDIT -. write fails .-> REFUSE["Run refused<br/>executor not called"]
The audit entry is written by Tracon before the call. A run whose entry cannot be written never reaches your executor, so an executor can neither run code without a record nor suppress one.
Implement the executor
Section titled “Implement the executor”The request carries everything a remote runner needs; the executor maps it to your runtime and maps the outcome back. This one posts the script to a runner service:
public sealed class RunnerServiceScriptExecutor(HttpClient runner) : ISkillScriptExecutor{ private static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web);
public async ValueTask<SkillScriptExecutionResult> ExecuteAsync( SkillScriptExecutionRequest request, CancellationToken cancellationToken = default) { using var response = await runner.PostAsync( "runs", JsonContent.Create(new { tenant = request.TenantId, runtime = request.Interpreter, source = request.Content, sourceSha256 = request.ContentSha256, standardInput = request.ArgumentsJson, timeoutSeconds = request.Timeout.TotalSeconds, maxOutputBytes = request.MaxOutputBytes, }), cancellationToken);
response.EnsureSuccessStatusCode();
var outcome = JsonSerializer.Deserialize<RunnerOutcome>( await response.Content.ReadAsStringAsync(cancellationToken), Web) ?? throw new InvalidOperationException("The runner service returned no outcome.");
return new SkillScriptExecutionResult { ExitCode = outcome.TimedOut ? null : outcome.ExitCode, StandardOutput = outcome.StandardOutput, StandardError = outcome.StandardError, Truncated = outcome.Truncated, TimedOut = outcome.TimedOut, }; }
private sealed record RunnerOutcome( int? ExitCode, string StandardOutput, string StandardError, bool Truncated, bool TimedOut);}Register it next to UseSkillScripts, before or after AddTracon() — a plain
AddSingleton wins in both orders. With a remote runner, an allow-list value can
name a runtime instead of an interpreter path:
builder.Services.AddHttpClient<RunnerServiceScriptExecutor>(client => client.BaseAddress = new Uri("https://script-runner.internal/"));builder.Services.AddSingleton<ISkillScriptExecutor>(provider => provider.GetRequiredService<RunnerServiceScriptExecutor>());
builder.AddTracon() .UseSkillScripts(o => { o.PlatformIsolationAcknowledged = true; o.AllowStoredScripts = true; o.Interpreters["py"] = "python-3.12"; }) .RequireCustomBinding<ISkillScriptExecutor>();RequireCustomBinding<ISkillScriptExecutor>() stops the host at startup while
the built-in executor is still the one that resolves — for example when a module
registers yours with TryAddSingleton after UseSkillScripts and the
registration is dropped. The acknowledgement flag stays required with any
executor: Tracon cannot verify the isolation an executor provides.
What the request carries
Section titled “What the request carries”| Member | Meaning |
|---|---|
TenantId |
The tenant whose grant opened the gates. Use it to pick a per-tenant runner; never read ITenantContext |
RunId |
The run that called the script, or null outside a run |
SkillName, ScriptName |
A stored script’s name is a plain file name. A file script’s name is its path inside the skill directory, such as scripts/fetch.py — check it before you use it as a file name |
Origin |
Stored (database or code, content-pinned) or File (under a skill root, not pinned). Throw for an origin you do not support |
Interpreter |
The allow-list value for the extension |
Content, ContentSha256 |
The script text, and the upper-case hexadecimal SHA-256 of its UTF-8 bytes |
SourcePath |
A file script’s host path; a remote runner cannot read it and runs Content |
ArgumentsJson |
The model’s arguments; write them to standard input, or close it empty when null |
Timeout, MaxOutputBytes |
Stop the script at the timeout; keep at most this many UTF-8 bytes of each output stream |
The request carries no environment value. Give the script TRACON_SKILL_NAME
(the skill name) and TRACON_SKILL_TEMP (a writable directory of its own) and
nothing from your host, so a script behaves the same under every executor.
Logging a request is safe: its text form leaves out Content and
ArgumentsJson.
Runtime contract
Section titled “Runtime contract”- Lifetime: singleton. One instance serves every run, concurrently. Make it thread-safe.
- A timeout is a result, a cancellation is an exception. Past
Timeout, stop the script and returnTimedOut = truewith no exit code. When the caller cancels the token, throwOperationCanceledException. - Tracon does not wait forever. It waits
Timeoutplus five seconds, then cancels the token, stops waiting, frees the concurrency slot, and records a timeout. Cancellation is cooperative: if your executor ignores the token, the script can keep running after Tracon has moved on. - Cap the output, do not annotate it. Set
Truncatedwhen you cut a stream. Tracon caps each stream again and adds the one truncation notice the model sees. - A consistent result or an exception. An exit code without
TimedOut, orTimedOutwithout an exit code — any other shape fails the call. A thrown exception fails it too; the model and the tool call record see only the exception type, and the message goes to the server log. - No delivery guarantee applies. Tracon calls the executor once per approved call and never retries. The model may call the script again; that call passes every gate again.
Isolation is the executor’s job
Section titled “Isolation is the executor’s job”Tracon does not isolate the script process. The built-in executor runs it under the host’s own operating-system identity, which can read what that identity reads — the threat model lists this as an accepted risk. Your executor is how a deployment closes it: no host environment, no host file system, a restricted network, and per-tenant resources. Tracon cannot see or check any of this, so the grant rules do not relax: in a multi-tenant host a stored script’s grant still needs platform authority.
Verify your implementation
Section titled “Verify your implementation”Add the contract package to your test project:
dotnet add package Tracon.Testing.Contracts.Xunit --prereleaseusing Tracon.Testing.Contracts.Scripts;
public sealed class RunnerServiceScriptExecutorTests : SkillScriptExecutorContract{ protected override string Interpreter => "sh";
protected override ValueTask<ISkillScriptExecutor> CreateExecutorAsync() => ValueTask.FromResult<ISkillScriptExecutor>( new RunnerServiceScriptExecutor(new HttpClient { BaseAddress = new Uri("http://localhost:8085/") }));}The inherited tests send POSIX sh scripts and check standard input, an empty
argument and an empty script, the exit code and standard error, the output cap
without a notice, the timeout, a caller cancellation, the two variables without
the host environment, separate temporary directories for concurrent calls, and
that a stored request runs its content and never a path. An executor for another
runtime overrides Extension, Interpreter, and the script bodies;
SkipReason skips the suite where the runtime is missing. Pair it with
ContractCoverage.MissingDerivedTypes(..., ContractCoverage.ScriptContracts) so
a new contract in a later package version does not go untested.
Read next
Section titled “Read next”- Skill scripts — the gates, grants, and content pin every executor sits behind
- Threat model — the operating-system identity risk an executor is there to close
- Testing — the contract packages and how to run them