Hankweave can route its Anthropic API traffic through an optional local HTTP proxy that sits between the runtime and the upstream API. The proxy is disabled by default (withoutProxy: true); when you enable it, requests pass through a middleware pipeline on the way out and responses pass back through it on the way in. Its only supported mode is passthrough – the module has no caching, load balancing, or authentication layer, and it is not intended to grow one.
This page is the reference for that behavior: how to enable the proxy, which traffic actually traverses it, how the request/response pipeline works, and what the middleware classes do. The command-line flags that turn it on are documented in reference/cli; here we cover what happens after you pass them.
What the LLM proxy is#
A passthrough proxy applies request middleware, forwards the request to its configured upstream, applies response middleware, and returns the response. The word "mode" should not suggest a choice among routing or caching strategies: ProxyRunner.start() accepts only passthrough, and no other value exists to select.
Enabling and configuring the proxy#
Use --proxy to enable the local proxy. A second flag, --without-proxy, sets withoutProxy: true for backward compatibility; it is redundant because the proxy is off by default, and it is not advertised in --help. The complete flag contracts belong to reference/cli.
Two settings in hankweave.json relate to the proxy but do different jobs. The configuration schema (the machine-readable field contract) lists them as:
| field | type | default | required | constraints | description |
|---|---|---|---|---|---|
withoutProxy | boolean | no | Bypass internal LLM proxy | ||
anthropicBaseUrl | string | no | Custom Anthropic API base URL (for corporate proxies) |
withoutProxy controls whether the proxy runs at all; anthropicBaseUrl selects where it sends traffic. Both have environment-variable counterparts – HANKWEAVE_RUNTIME_WITHOUT_PROXY and HANKWEAVE_RUNTIME_ANTHROPIC_BASE_URL – mapped through the generic HANKWEAVE_RUNTIME_* loader. For the boolean variable, only "true" and "1" parse as true; every other non-empty value, including "false", parses as false. This produces a genuinely confusing combination: HANKWEAVE_RUNTIME_WITHOUT_PROXY=false enables the proxy, while the default withoutProxy: true disables it. Configuration layering is described in concepts/hanks, and the generated environment-variable entries are in reference/environment-variables.
The base-URL setting has its own trap. anthropicBaseUrl, HANKWEAVE_RUNTIME_ANTHROPIC_BASE_URL, and --anthropic-base-url select the proxy's upstream – the default upstream is https://api.anthropic.com – but none of them enable the proxy. When the proxy is disabled, --anthropic-base-url is not consumed for codon traffic (model work performed by a Hankweave task). If you set a custom upstream intending it for the internal proxy, enable the proxy separately with --proxy.
Ports and lifecycle#
Once enabled, the proxy starts after the WebSocket server (the persistent client-connection service) binds. Its preferred port is the actual WebSocket port plus one; because the WebSocket port is OS-assigned by default, a port such as 7778 is illustrative, not a fixed default. If that preferred port fails with an address-in-use-class error, the proxy retries with an OS-assigned dynamic port. The +1 value is an adjacent-port convention; no rationale for it is documented.
Startup leaves three traces you can use to find the running proxy. The startup console box includes Proxy: http://localhost:<port>. The execution lock file is updated with proxyPort; lock-file discovery belongs to integrate/deployment-model. And since 0.4.1, the connected client also receives an optional proxyPort in server.ready; that event is not journaled, and its contract is in reference/events. The proxy stops when the server shuts down.
Two paths bypass the middleware pipeline entirely. GET /health and GET / return status 200, content type text/plain, and the body Hankweave Proxy OK, so you can probe a running proxy directly:
curl http://localhost:7778/health
# Hankweave Proxy OK
The port in this example is illustrative; use the port shown at startup or exposed as proxyPort. Every other path enters the middleware pipeline described below.
One lifecycle caveat concerns idle timeouts. The help text says --idle-timeout applies to WebSocket and proxy servers, but at this tag the configured value is not passed to ProxyRunner, so the proxy uses its constructor default of 0 (no timeout).
Which codon traffic goes through the proxy#
Enabling the proxy does not route all of Hankweave's model traffic through it. The proxy URL has exactly one consumer: codon dispatch. When the proxy runs, codons dispatched to the Claude Agent SDK harness receive ANTHROPIC_BASE_URL set to the local proxy URL, overriding any ambient value.
Read the diagram as text
With the local proxy enabled:
Claude Agent SDK codons
(Anthropic API requests)
|
| ANTHROPIC_BASE_URL
v
local LLM proxy -- HttpTransport --> configured Anthropic upstream
Pi harness codons -----------+
+-- bypass the proxy --> model provider's API
sentinel calls / self-tests -+
Everything else resolves its endpoint elsewhere. Codon self-tests and sentinel model calls do not receive the proxy URL. Sentinel calls resolve through the provider registry; the Anthropic provider factory supplies an API key but no base URL, so those calls use the SDK-default Anthropic endpoint regardless of the proxy.
Codons on the Pi harness never traverse the proxy at all. The Pi manager receives no proxy base URL, and Pi does not use environment base URLs for this routing. Conversely, with the proxy disabled, an ambient ANTHROPIC_BASE_URL is passed through to the Claude Agent SDK unchanged – which is why the base-URL setting in the previous section only matters when the proxy is on.
The request/response pipeline#
For traffic that does enter the proxy, the pipeline runs in a fixed order. The proxy extracts request headers and body, attempts to parse the body as JSON matching claudeApiRequestSchema.passthrough(), applies request middleware in registration order, forwards through the transport, applies response middleware in order, and returns the final Response. A failed parse only produces a debug log entry; the request still forwards with its raw body.
Middleware receives the request without body and receives the body separately, a split that matters when you read the middleware source in the next section. When claudeRequestData is present, the proxy serializes that structured value back into the body; otherwise it keeps the original body. HttpTransport rewrites Host for the target host and removes Content-Length so it can be recalculated after a body change.
Error and streaming behavior is deliberately plain. A non-200 upstream response is logged as an error and forwarded with its original status and body. Responses marked text/event-stream or chunked are forwarded as streams; middleware that reads a stream consumes it. Any exception during processing returns status 500 with body Proxy Error.
When middleware is source-internal#
The middleware classes are not a public extension API. LLMProxyMiddleware, LoggingMiddleware, and HttpTransport are not exported from the module, and the published package exports only ., ./schemas, and ./types. The source module's exported symbols include LLMProxyRequest, LLMProxyResponse, LLMTransport, DoubleMaxTokens, LLMProxy, createPassthroughProxy, and ProxyRunner; these are contributor building blocks, not package-reachable middleware APIs.
LLMProxy.addMiddleware and removeMiddleware exist, but registration is source-level: there is no user-facing registration API, configuration key, or plugin path. The request and response shapes below are shown for contributors reading or modifying the source:
export interface LLMProxyRequest {
/** HTTP method (GET, POST, etc.) */
method: string;
/** Request URL path and query parameters */
url: string;
/** HTTP headers as key-value pairs */
headers: Record<string, string>;
/** Request body content */
body?: string;
/** Parsed Claude API request data if the body contains a valid Claude request */
claudeRequestData?: ClaudeApiRequest;
}
/**
* LLMProxyRequest without the body field, used in middleware processing. Body is removed to avoid temptation to modify it directly.
*/
type LLMProxyRequestWithoutBody = Omit<LLMProxyRequest, "body">;
/**
* Represents a response from the LLM proxy
*/
export interface LLMProxyResponse {
/** HTTP status code */
status: number;
/** Response headers as key-value pairs */
headers: Record<string, string>;
/** Response body, either as string or streaming data */
body?: string | ReadableStream;
}
Note the relationship between these types and the pipeline described above. The hooks use handleRequest(request: LLMProxyRequestWithoutBody, body?: string) and handleResponse(response: LLMProxyResponse). The transcluded demonstration annotates its parameter as LLMProxyRequest, but processing supplies the body-less request and separate body argument. A middleware changes structured claudeRequestData rather than assigning the raw body directly.
In a default run, createPassthroughProxy installs exactly one middleware, LoggingMiddleware. It logs method and URL; for Claude requests it logs model, messages, max_tokens, and stream, while other bodies are truncated at 500 characters. Proxy activity is written to .hankweave/logs/server.log in the execution directory with [LLM-PROXY], [PROXY-HTTP-TRANSPORT], and [LOGGING-MIDDLEWARE] tags. Log inspection belongs to operate/observe-and-debug. A typical exchange looks like this:
[LOGGING-MIDDLEWARE] Received request POST /v1/messages
[LOGGING-MIDDLEWARE] Claude request - model=claude-haiku-4-5, messages=5, max_tokens=8192, stream=true
---
[LOGGING-MIDDLEWARE] Response status: 200
---
The source also ships DoubleMaxTokens, an exported demonstration middleware. It doubles max_tokens when a Claude request includes it, but it is not installed by default and is not importable from the published package:
export class DoubleMaxTokens extends LLMProxyMiddleware {
/**
* Modify the request to double the max_tokens parameter if present
* @param req - The request to modify
* @returns Promise resolving to the modified request
*/
override async handleRequest(req: LLMProxyRequest): Promise<LLMProxyRequest> {
if (req.claudeRequestData?.max_tokens) {
const originalMaxTokens = req.claudeRequestData.max_tokens;
req.claudeRequestData.max_tokens = originalMaxTokens * 2;
}
return req;
}
}
// -------------=
Treat it as an example of the hook contract, not an available feature. Source-level middleware changes require contributor work; see contribute/runtime-architecture.
Limitations#
Structured claudeRequestData exists only for bodies matching the Anthropic Messages API shape. Other traffic remains raw-body passthrough, and middleware cannot edit that raw body directly.
The proxy is intentionally limited to passthrough behavior: it does not provide caching, load balancing, or an authentication layer. For the owning references, see reference/cli for flags, reference/hankweave-json for configuration keys, reference/environment-variables for HANKWEAVE_RUNTIME_* variables, reference/events for server.ready.proxyPort, integrate/deployment-model for lock-file discovery, contribute/runtime-architecture for internals, and operate/authentication-and-models for harness selection.