Tether
DocsGitHub

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:

  • ClaimAsync must be atomic against every caller that could share the key. It answers Fresh (you now own the key and must run the effect), InFlight (another request is running it right now) or Done (it already ran; the result is returned and the handler is not called).
  • CompleteAsync records the result and releases the claim.
  • ReleaseAsync gives 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

SituationStatuserror
Signature does not match401invalid_signature
Timestamp outside tolerance401stale_timestamp
Write without execute mode400execute_mode_required
No handler for that tool400unknown_tool
Body is not JSON400invalid_body
Your handler threw500tool_failed, with the message
Replayed request_id200the first result, X-Tether-Replayed: true

A failed call is not cached. The retry reaches your handler.