Skip to content

HttpTool.Of

C# · package Toolnexus · SPEC §7 · HttpTool.cs

public static HttpTool Of(HttpTool.Options opts)

Declares an HTTP endpoint as an ITool with Source set to "http". The model’s arguments become URL placeholders, query parameters or a request body according to the options, the response body becomes the tool output, and a non-2xx status becomes a tool error.

No HttpClient of your own, no request-building code, no per-endpoint wrapper class — the endpoint becomes a tool by description.

When the capability you want to expose is already a REST call: an internal microservice, a public JSON API, a webhook, an admin endpoint. Declaring it beats writing a NativeTool around HttpClient, because argument routing and error mapping are handled for you.

1. A GET endpoint, arguments as query parameters

Section titled “1. A GET endpoint, arguments as query parameters”

For GET (and HEAD), every leftover argument goes to the query string — you do not need to list them in Query.

using System.Net;
using System.Net.Sockets;
using System.Text;
using Toolnexus;
static int FreePort()
{
var probe = new TcpListener(IPAddress.Loopback, 0);
probe.Start();
var p = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
return p;
}
// A throwaway loopback server so this example is hermetic — no public network.
var port = FreePort();
var server = new HttpListener();
server.Prefixes.Add($"http://127.0.0.1:{port}/");
server.Start();
var serving = Task.Run(async () =>
{
var c = await server.GetContextAsync();
var body = Encoding.UTF8.GetBytes($$"""{"seen":"{{c.Request.Url!.PathAndQuery}}"}""");
c.Response.StatusCode = 200;
c.Response.ContentType = "application/json";
c.Response.ContentLength64 = body.Length;
await c.Response.OutputStream.WriteAsync(body);
c.Response.Close();
});
try
{
var search = HttpTool.Of(new HttpTool.Options
{
Name = "search_docs",
Description = "Search the documentation index",
Method = "GET",
Url = $"http://127.0.0.1:{port}/search",
InputSchema = new Dictionary<string, object?>
{
["type"] = "object",
["properties"] = new Dictionary<string, object?> { ["q"] = new Dictionary<string, object?> { ["type"] = "string" } },
["required"] = new[] { "q" },
},
});
if (search.Source != "http") throw new Exception(search.Source);
var res = await search.ExecuteAsync(new Dictionary<string, object?> { ["q"] = "http tool" });
if (res.IsError) throw new Exception(res.Output);
// Values are URL-encoded on the way out.
if (!res.Output.Contains("/search?q=http%20tool")) throw new Exception(res.Output);
// Metadata always carries the HTTP status.
if (Convert.ToInt32(res.Metadata?["status"]) != 200) throw new Exception("status metadata");
await serving;
Console.WriteLine($"ok: {res.Output}");
}
finally
{
server.Stop();
}

2. A POST with a URL placeholder, a JSON body and an ${ENV} header

Section titled “2. A POST with a URL placeholder, a JSON body and an ${ENV} header”

{placeholder} segments in the URL are filled from the arguments and consumed — they do not also appear in the body. Whatever is left becomes the JSON body.

using System.Net;
using System.Net.Sockets;
using System.Text;
using Toolnexus;
static int FreePort()
{
var probe = new TcpListener(IPAddress.Loopback, 0);
probe.Start();
var p = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
return p;
}
var port = FreePort();
var server = new HttpListener();
server.Prefixes.Add($"http://127.0.0.1:{port}/");
server.Start();
string seenPath = "", seenAuth = "", seenBody = "", seenType = "";
var serving = Task.Run(async () =>
{
var c = await server.GetContextAsync();
seenPath = c.Request.Url!.PathAndQuery;
seenAuth = c.Request.Headers["Authorization"] ?? "";
seenType = c.Request.ContentType ?? "";
using (var reader = new StreamReader(c.Request.InputStream, Encoding.UTF8)) seenBody = await reader.ReadToEndAsync();
var body = Encoding.UTF8.GetBytes("""{"ok":true}""");
c.Response.StatusCode = 201;
c.Response.ContentLength64 = body.Length;
await c.Response.OutputStream.WriteAsync(body);
c.Response.Close();
});
// The token is read from the environment at CALL time and never appears in the tool
// definition. YOUR_KEY_HERE is an obvious placeholder, not a credential.
Environment.SetEnvironmentVariable("DOCS_DEMO_TOKEN", "YOUR_KEY_HERE");
try
{
var comment = HttpTool.Of(new HttpTool.Options
{
Name = "add_comment",
Description = "Add a comment to an issue",
Method = "POST",
Url = $"http://127.0.0.1:{port}/issues/{{issue}}/comments",
Headers = new Dictionary<string, string> { ["Authorization"] = "Bearer ${DOCS_DEMO_TOKEN}" },
Body = "json",
InputSchema = new Dictionary<string, object?>
{
["type"] = "object",
["properties"] = new Dictionary<string, object?>
{
["issue"] = new Dictionary<string, object?> { ["type"] = "string" },
["text"] = new Dictionary<string, object?> { ["type"] = "string" },
},
["required"] = new[] { "issue", "text" },
},
});
var res = await comment.ExecuteAsync(new Dictionary<string, object?>
{
["issue"] = "42",
["text"] = "looks good",
});
if (res.IsError) throw new Exception(res.Output);
if (res.Output != """{"ok":true}""") throw new Exception(res.Output);
if (Convert.ToInt32(res.Metadata?["status"]) != 201) throw new Exception("status");
await serving;
// `issue` was consumed by the URL; only `text` survived into the body.
if (seenPath != "/issues/42/comments") throw new Exception(seenPath);
if (seenBody != """{"text":"looks good"}""") throw new Exception(seenBody);
if (seenType != "application/json") throw new Exception(seenType);
// ${DOCS_DEMO_TOKEN} was expanded from the environment.
if (seenAuth != "Bearer YOUR_KEY_HERE") throw new Exception("header expansion");
Console.WriteLine($"ok: {seenPath} <- {seenBody}");
}
finally
{
server.Stop();
}

3. Failures, explicit Query on a POST, and ResultMode

Section titled “3. Failures, explicit Query on a POST, and ResultMode”

A non-2xx response is a tool error whose output is HTTP <status>: <body> — the model reads it and can correct itself. Network failures and timeouts land the same way.

using System.Net;
using System.Net.Sockets;
using System.Text;
using Toolnexus;
static int FreePort()
{
var probe = new TcpListener(IPAddress.Loopback, 0);
probe.Start();
var p = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
return p;
}
var port = FreePort();
var server = new HttpListener();
server.Prefixes.Add($"http://127.0.0.1:{port}/");
server.Start();
var seenPaths = new List<string>();
var seenBodies = new List<string>();
var serving = Task.Run(async () =>
{
for (var i = 0; i < 2; i++)
{
var c = await server.GetContextAsync();
seenPaths.Add(c.Request.Url!.PathAndQuery);
using (var reader = new StreamReader(c.Request.InputStream, Encoding.UTF8))
seenBodies.Add(await reader.ReadToEndAsync());
var notFound = c.Request.Url!.AbsolutePath.StartsWith("/missing");
var body = Encoding.UTF8.GetBytes(notFound ? """{"error":"no such record"}""" : """{"id":9,"state":"queued"}""");
c.Response.StatusCode = notFound ? 404 : 200;
c.Response.ContentLength64 = body.Length;
await c.Response.OutputStream.WriteAsync(body);
c.Response.Close();
}
});
try
{
// Query names are routed to the querystring even on a POST; the rest becomes the body.
var enqueue = HttpTool.Of(new HttpTool.Options
{
Name = "enqueue_job",
Description = "Enqueue a background job",
Method = "POST",
Url = $"http://127.0.0.1:{port}/jobs",
Query = new List<string> { "priority" },
Body = "json",
ResultMode = "status+text",
Timeout = 5_000,
});
var ok = await enqueue.ExecuteAsync(new Dictionary<string, object?>
{
["priority"] = "high",
["kind"] = "reindex",
});
if (ok.IsError) throw new Exception(ok.Output);
// ResultMode "status+text" prefixes the status code and a newline.
if (ok.Output != "200\n{\"id\":9,\"state\":\"queued\"}") throw new Exception(ok.Output);
var missing = HttpTool.Of(new HttpTool.Options
{
Name = "get_record",
Description = "Fetch a record",
Method = "GET",
Url = $"http://127.0.0.1:{port}/missing",
});
var bad = await missing.ExecuteAsync(new Dictionary<string, object?>());
if (!bad.IsError) throw new Exception("expected IsError");
if (bad.Output != """HTTP 404: {"error":"no such record"}""") throw new Exception(bad.Output);
// The status is on the metadata of the error result too.
if (Convert.ToInt32(bad.Metadata?["status"]) != 404) throw new Exception("status metadata");
await serving;
if (seenPaths[0] != "/jobs?priority=high") throw new Exception(seenPaths[0]);
if (seenBodies[0] != """{"kind":"reindex"}""") throw new Exception(seenBodies[0]);
Console.WriteLine($"ok: {seenPaths[0]} | error: {bad.Output}");
}
finally
{
server.Stop();
}
Field Type Default What it does
Name string "" Tool name the model calls. Must match [a-zA-Z0-9_-].
Description string "" What the model reads to decide whether to call it.
Method string "GET" Upper-cased internally, so "post" works.
Url string "" May contain {placeholder} segments filled from the arguments.
Headers IDictionary<string, string>? null Values expand ${ENV_VAR} at call time; never logged.
Query List<string>? null Argument names routed to the querystring. Ignored for GET — everything goes there anyway.
Body string? "json" "json", "form", or "raw" (sends the body argument verbatim).
InputSchema IDictionary<string, object?>? empty object The JSON Schema the model sees.
Timeout long? 30000 Milliseconds. Overridden by ToolContext.TimeoutMs.
ResultMode string? text "json" re-serializes the parsed body; "status+text" prefixes "<status>\n".

Argument routing, in order: URL placeholders first, then query names (or everything, for GET), and whatever remains becomes the body. GET and HEAD never send a body.