SDKs
.NET SDK
net8.0, no package references — System.Text.Json and System.Security.Cryptography are
in-box, so the SDK never brings a transitive advisory into your service.
The endpoint
using Tether.Sdk;
var tools = new Dictionary<string, TetherTool>
{
["get_order_status"] = TetherTool.Read(
"Look up one order by its number and return its fulfilment state.",
(args, ctx) => db.Orders.Status(ctx.Tenant.ScopeId, (string?)args["order_id"]),
schema: new JsonObject
{
["type"] = "object",
["properties"] = new JsonObject
{
["order_id"] = new JsonObject { ["type"] = "string" }
},
["required"] = new JsonArray("order_id")
},
domain: "orders"),
["cancel_order"] = TetherTool.Write_(
"Cancel an order that has not yet been dispatched.",
risk: "elevated",
(args, ctx) => db.Orders.Cancel((string?)args["order_id"]),
domain: "orders"),
};
var tether = new TetherEndpoint(builder.Configuration["Tether:Secret"]!, tools);
TetherTool.Write_ takes the risk as a required argument rather than defaulting it. A write
registered without one falls to the permissive default rule — any active user, one approver — so
a default here would quietly produce a write anybody can approve. See
Choosing risk levels.
Nothing infers the write flag from a name. delete_everything declared with TetherTool.Read
is a read, and will be dispatched inline.
Wiring it in
HandleAsync(headers, rawBody) returns a ToolResponse with Status, Body, Headers and
Json(). It takes no ASP.NET Core dependency, so it fits any hosting shape.
Minimal API
app.MapPost("/tether", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body);
var headers = request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString());
var result = await tether.HandleAsync(headers, await reader.ReadToEndAsync());
return Results.Content(result.Json(), "application/json", statusCode: result.Status);
});
Controller
[HttpPost("/tether")]
public async Task<IActionResult> Post()
{
using var reader = new StreamReader(Request.Body);
var headers = Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString());
var result = await tether.HandleAsync(headers, await reader.ReadToEndAsync());
return new ContentResult
{
Content = result.Json(),
ContentType = "application/json",
StatusCode = result.Status
};
}
The context argument
(args, ctx) =>
{
ctx.Tenant.OrganizationId; // who this turn belongs to
ctx.Tenant.ScopeId; // the site, ledger or region, when the tenant has several
ctx.Tenant.UserId;
ctx.Tool; // the tool being called
ctx.IsWrite; // as you declared it
ctx.Mode; // "execute" on a confirmed write, null otherwise
ctx.RequestId; // also the idempotency key
ctx.Customer?.Id; // the verified end customer, or null
}
Scope every query by ctx.Tenant, never by anything in args. The tenant came from the
credential and cannot be influenced by the model; an argument is whatever the model decided to
send.
Enforce ownership with the verified customer. ctx.Customer is the end customer Tether verified from the
token your server signed (Verified customers), or null for an
anonymous turn. Refuse a booking, order or account it does not own, whatever the arguments say: a
customer_id in the arguments is something the model wrote. Declare RequiresCustomer = true on a tool that acts for
one customer and Tether neither calls nor stages it without one. Your host mints the token with TetherCustomerToken.Mint(key, tenantId, customerId, lifetime).
For an asynchronous body, set Handler directly rather than using the Read / Write_
helpers:
["get_order_status"] = new TetherTool
{
Description = "Look up one order by its number and return its fulfilment state.",
Domain = "orders",
Handler = async (args, ctx, ct) => await db.Orders.StatusAsync((string?)args["order_id"], ct),
}
Registering the catalog
var catalog = TetherCatalog.Build(tools);
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("X-Api-Key", key);
await http.PutAsync($"{baseUrl}/v1/tools",
new StringContent(catalog.ToJsonString(), Encoding.UTF8, "application/json"));
TetherCatalog.Build throws CatalogException with the engine's own message when a definition
is incomplete, so a broken catalog fails at your build rather than at registration.
Lifecycle events
var tether = new TetherEndpoint(secret, tools, onEvent: async (envelope, ctx, ct) =>
{
if ((string?)envelope["event"] == TetherEvents.ActionPending)
await NotifyApproversAsync((string?)envelope["action_id"], ct);
if ((string?)envelope["event"] == TetherEvents.ConversationHandoff)
await OpenTicketAsync((string?)envelope["session_id"], ctx.Customer, ct);
});
Omit onEvent and the event is acknowledged and dropped. TetherEvents.All lists every event,
including action.verified and action.unverified for a write's
verified outcome, action.precondition_failed for a
precondition that no longer held at confirmation,
action.provider_refused for a confirmed write an imported API
refused, and conversation.handoff for a conversation
handed to a person.
A store that survives more than one process
MemoryIdempotencyStore is per-process. Behind a load balancer or across a deploy, a replayed
request reaches a process that never saw the original and runs your handler again, which for a
write means twice.
The store contract is three operations, not two, because a check followed by a write cannot stop two overlapping requests from both running the effect:
ClaimAsyncmust be atomic against every caller that could share the key. It answersFresh(you now own the key and must run the effect),InFlight(another request is running it right now) orDone(it already ran; the result is returned and the handler is not called).CompleteAsyncrecords the result and releases the claim.ReleaseAsyncgives up a claim whose effect did not happen, so a retry may proceed.
In Redis the claim is one SET ... NX:
public sealed class RedisIdempotencyStore(IDatabase redis) : IIdempotencyStore
{
private const string Claimed = "claimed";
private static readonly TimeSpan ClaimLease = TimeSpan.FromMinutes(5);
private static readonly TimeSpan Retention = TimeSpan.FromDays(1);
private static string Key(string requestId) => $"tether:{requestId}";
public async Task<IdempotencyClaim> ClaimAsync(string requestId, CancellationToken ct)
{
if (await redis.StringSetAsync(Key(requestId), Claimed, ClaimLease, When.NotExists))
{
return new IdempotencyClaim(ClaimState.Fresh);
}
var held = await redis.StringGetAsync(Key(requestId));
return !held.HasValue || held == Claimed
? new IdempotencyClaim(ClaimState.InFlight)
: new IdempotencyClaim(ClaimState.Done, JsonNode.Parse((string)held!));
}
public Task CompleteAsync(string requestId, object? result, CancellationToken ct) =>
redis.StringSetAsync(Key(requestId), JsonSerializer.Serialize(result), Retention);
public async Task ReleaseAsync(string requestId, CancellationToken ct)
{
if (await redis.StringGetAsync(Key(requestId)) == Claimed)
{
await redis.KeyDeleteAsync(Key(requestId));
}
}
}
The claim lease is how long a crashed process can hold a key before another request may take it,
so keep it longer than your slowest write. Pass the store as
new TetherEndpoint(secret, tools, store: new RedisIdempotencyStore(redis)).
Cancellation
A cancelled request propagates OperationCanceledException rather than becoming a
tool_failed. The caller giving up is not your tool failing, and conflating the two would put a
phantom failure in your logs every time a turn timed out.
What it returns
| Situation | Status | error |
|---|---|---|
| Signature does not match | 401 | invalid_signature |
| Timestamp outside tolerance | 401 | stale_timestamp |
| Write without execute mode | 400 | execute_mode_required |
| No handler for that tool | 400 | unknown_tool |
| Body is not JSON | 400 | invalid_body |
| Your handler threw | 500 | tool_failed, with the message |
Replayed request_id | 200 | the first result, X-Tether-Replayed: true |
A failed call is not cached. The retry reaches your handler.
Related
- SDKs — what all three share, and where they differ
- Retries, timeouts, idempotency
- Choosing risk levels — what
Write_asks you for - Testing with mock mode — exercise it with no provider key