TypeScript¶
@harnessweaver/core is a native TypeScript port of the core: it loads the same harness.yaml (layers, locks, bounds, built-in and org profiles, overlays, levels) and runs the same hook bus, budgets and cost, guards, cel-lite policy and rules, routing, tool governor, context governance, prompts and eval graders, held to the same language-neutral fixtures as Python (RULES R11). @harnessweaver/ai-sdk governs Vercel AI SDK (v7) models with it.
TypeScript runs the Session style: your code owns the flow, and the harness governs each step. Model calls are your AI SDK model's, wrapped with wrapModel.
import { load } from "@harnessweaver/core";
import { wrapModel } from "@harnessweaver/ai-sdk";
import { generateText } from "ai";
const harness = load("harness.yaml"); // HARNESSWEAVER_ENV picks the overlay
const answer = await harness.session({ task: "support", principal: { id: user.id, roles: ["customer"] }, tenant: user.org }, async (s) => {
const question = String(await s.guard("input", text)); // input.before: redacts or blocks
const route = await s.decide("intent", { text: question }); // route.decide
const articles = await s.context("help").fetch(question); // ACLs, labels, caps, context.after
const prompt = await s.prompt("answer@1", { question, intent: String(route.choice), articles: listing(articles) });
const { text: reply } = await generateText({ model: wrapModel(s, model, { alias: "fast" }), prompt });
return s.validate("output", reply); // output.before: guards and rules
});
sequenceDiagram
participant App
participant S as Session
participant W as wrapModel
participant M as AI SDK model
App->>S: s.guard / s.decide / s.context(...).fetch
App->>W: generateText / streamText
W->>S: allowed aliases, budgets, model.before (may redact)
W->>M: doGenerate / doStream with the redacted prompt
M-->>W: answer, or deltas
W->>S: stream.chunk per delta, then usage, cost and model.after
W-->>App: the answer as the hooks left it
App->>S: s.validate("output", text)
Example 04 is a complete app: Example 02's spec from TypeScript, with tests that compare its answers with Python's.
Install¶
npm install @harnessweaver/core, plus npm install @harnessweaver/ai-sdk ai @ai-sdk/provider for the AI SDK adapter. Node 22 or later; ESM only. From a clone of the repository: pnpm -C typescript install && pnpm -C typescript build.
Loading¶
| Option | Meaning |
|---|---|
env |
The overlay harness.<env>.yaml; defaults to $HARNESSWEAVER_ENV. A missing file is fine |
profileDirs |
Extra directories to search for org:// profiles, before $HARNESSWEAVER_PROFILE_PATH |
plugins |
Plugins the spec may use (definePlugin); JavaScript has no entry points, so they are passed in |
tools |
Tool handlers by name: { refund_payment: (args, env) => ... }. Each tool still needs its manifest |
clock |
An injected clock; new FakeClock() in tests |
sidecar |
A SidecarClient for guards declared where: sidecar; defaults to one built from the spec's sidecar section. A lock on sidecar.url doesn't bind it: it is app code, which runs with the app's authority |
Loading fails with a SpecError subclass for anything the spec asks TypeScript to enforce that it can't: an unknown provider, a guard or routing stage with no TypeScript provider (unless the guard is where: sidecar), and every check Python makes (locks, bounds, levels, expressions, unknown keys). harness.name, harness.spec (the EffectiveSpec, including spec.data, the merged spec, and spec.level) and harness.hooks(task?) describe what was loaded.
Sessions¶
run.start fires (with any run.start policy rules), fn runs, and run.end fires with the run's result, also when fn throws (after run.error). A control that stops the run throws in your code.
| Option | Meaning |
|---|---|
task |
The task whose budget, guards and approvals apply |
principal |
A principal id, or { id, roles, attrs } |
tenant |
The tenant (default default) |
budget |
Tighter limits for this run (deadline in milliseconds) |
attributes |
App tags for the envelope |
input |
The run's input, for run.start and the result |
onEnd |
Called with the RunResult (status, output, error, usage, cost, trace) when the session ends, completed or not |
| Session method | What it does |
|---|---|
s.guard("input", content) |
Input guards and middleware (input.before); returns the content, possibly redacted |
s.decide(router, { text, options, state, default }) |
A routing decision (route.decide) |
s.context(source).fetch(query, { limit }) |
Retrieve from a context source, governed, then context.after |
s.context(source).check(items, { query }) |
Govern items your code retrieved |
s.prompt(ref, variables) |
Render a versioned prompt |
s.tool(name, args) |
A governed tool call, through the handler given to load |
s.validate("output", value) |
Output guards and rules (output.before); throws ValidationFailed when one asks for a repair, so your code can ask the model again |
s.generate(request, call) |
One governed model call through call (what wrapModel uses) |
s.run, s.runId, s.envelope, s.trace |
The run's context, id, envelope and trace |
Errors are the same taxonomy as Python, exported from @harnessweaver/core: RunBlocked (and ModelRefusal, the RunBlocked for a model that declined), RunEscalated (both RunInterrupted), ValidationFailed, BudgetExceeded, SpecError and its subclasses, ProviderError, and the rest, each with the same stable code.
Models¶
@harnessweaver/ai-sdk governs AI SDK v7 models with a LanguageModelV4Middleware:
import { harnessMiddleware, wrapModel } from "@harnessweaver/ai-sdk";
import { wrapLanguageModel } from "ai";
const governed = wrapModel(s, openai("gpt-5"), { alias: "fast" });
// or, composed with other AI SDK middleware:
const composed = wrapLanguageModel({ model: openai("gpt-5"), middleware: [harnessMiddleware(s, { alias: "fast" }), other] });
Every call through the wrapped model:
- checks the alias against
models.allowed_aliases(pass the spec's alias with{ alias }; without one the model id is checked) and the run's budget; - fires
model.before; text the middleware changed (a redaction) is written back into the prompt the model receives: system, user and tool-result text, never the model's own earlier answers; - calls the model, records usage and cost (from
models.prices), and firesmodel.after, whose text changes reach your app; aRetrythere asks the model once more; - for
streamText, firesstream.chunkfor every text delta (with the text before it), so stream guards can redact a chunk or cut the stream, and closes the call on the stream's finish. ARetryatmodel.aftercan't re-ask a model whose words were already sent, so it blocks.
The model's own errors pass through unchanged, so the AI SDK's retries still work; the session's run.end records the failure.
A model that declines. An answer whose finish reason is content-filter throws ModelRefusal, a RunBlocked with the code model_refusal, from the call or the stream, and the run ends blocked with its usage and cost counted. @ai-sdk/anthropic reports Claude's refusals that way, and a content filter that cut an answer short looks the same, so its partial text is dropped. model.after doesn't fire. Two cases need an option, because the signal is late or missing:
| Case | What happens by default | Option |
|---|---|---|
| The signal comes with the stream's finish, after text has been streamed | The stream errors, but those parts have already been delivered | holdStream: true delivers a stream's parts only once it has finished. Output is no longer live |
A provider drops the refusal, as @ai-sdk/openai 4 does with OpenAI's refusal field |
An empty answer that completes normally | blockEmptyAnswers: true. Off by default, because a model may also end a tool loop with nothing to add |
A refusal sent as ordinary text carries no marker: check for its wording with a regex.deny output guard and s.validate("output", text).
Offline. The TypeScript core has no scripted provider. Example 04's offline.ts shows an AI SDK model that answers from a spec's offline models.rules and models.replies, so the same overlay works in both languages; in tests, the AI SDK's MockLanguageModelV4 (ai/test) works well.
Tools¶
const harness = load("harness.yaml", {
tools: { lookup_order: async ({ order_id }) => db.orders.find(String(order_id)) },
});
await harness.session({ principal: "casey" }, (s) => s.tool("lookup_order", { order_id: "A-1001" }));
The governor runs steps 1 to 7 and step 9: the manifest and digest, the schema, policy and approval rules, the trifecta, rate limits, then sanitizing the result (Tools). A call that needs approval escalates. The http, code and mcp transports are planned for TypeScript.
Plugins¶
import { defineMiddleware, definePlugin, defineProvider, outcome, type GuardResult } from "@harnessweaver/core";
export const acme = definePlugin({
namespace: "acme",
version: "1.0.0",
providers: [
defineProvider({
port: "Guardrail",
name: "banned_words", // becomes acme.banned_words
deterministic: true,
settings: { properties: { words: { type: "array", items: { type: "string" }, default: ["darn"] } } },
create: (settings) => ({
check: (content: string): GuardResult =>
(settings.words as string[]).some((w) => content.includes(w))
? { action: "block", reason: "a banned word", findings: [{ type: "banned" }] }
: { action: "allow" },
}),
}),
],
middleware: [
defineMiddleware({ name: "tag", hooks: ["input.before"], fail_mode: "closed", handler: () => outcome.continue() }),
],
});
const harness = load("harness.yaml", { plugins: [acme] }); // use: acme.banned_words, middleware: [{ use: acme.tag }]
defineProvider({ port, name, deterministic, stages, settings, create }):portisGuardrail,PolicyEngine,Decider,Evaluator,ModelProvider,ContextProviderorEmbedder;settingsis a JSON Schema whose defaults are filled in beforecreate(settings)runs.defineMiddleware({ name, hooks, fail_mode, band, timeout, handler }):fail_modeis required, as in Python. The handler receives(event, envelope)and returns an outcome (outcome.continue(modified),outcome.block(reason, code), ...) or nothing.definePlugin({ namespace, version, contract, providers, middleware, profiles }): bare names get the namespace; a name in another namespace, or in a reserved one, is refused; the contract range is checked (V5);plugins.allowapplies (S8).
The sidecar client¶
SidecarClient calls harnessd from TypeScript:
import { SidecarClient } from "@harnessweaver/core";
const sidecar = new SidecarClient({ url: "http://127.0.0.1:7070", tokenEnv: "HARNESSD_TOKEN" });
const result = await sidecar.startRun({ task: "support", input: question, principal: { id: "gina", roles: ["customer"] }, tenant: "globex" });
| Option | Meaning |
|---|---|
url |
harnessd's address |
token / tokenEnv |
The bearer token, or the variable it is read from on each request |
fetch |
A fetch to use instead of the global one (tests, proxies) |
timeoutMs |
A per-request timeout |
Methods map one to one onto the routes: health, spec, hooks, checkStage, checkProvider, decide, evaluatePolicy, authorize, sanitize, validate, startRun, getRun, answerApproval and runEvals. An ErrorInfo answer is thrown as the taxonomy error it names. In test mode the client refuses to call out unless you pass a fetch. A guard declared where: sidecar uses the same client to run its provider in harnessd (harnessd).
What differs from Python¶
| Python | TypeScript | |
|---|---|---|
| Integration styles | Wrap, Session, Run | Session (with wrapModel for models) |
| Model providers | scripted, openai.chat, anthropic.messages |
The app's AI SDK model, through wrapModel |
Suspend (approvals) |
Pauses a harness-driven run durably | Escalates: a TypeScript session can't wait for a person |
| Routing stages | rules, lexical, semantic, llm |
rules, lexical; semantic and llm planned |
| Tool transports | native, http, code |
Handlers passed to load; http, code, mcp planned |
| Telemetry | OpenTelemetry API, otel.sdk |
The trace on every RunResult; OpenTelemetry export planned |
| Plugins | Entry points or plugins.local |
Passed to load({ plugins }) |
Numbers follow Python exactly where the fixtures need it: cel-lite ints are bigint, so 7 / 2 is 3; canonical JSON (for digests and graders) matches Python's; string lengths and positions count code points.
Working on the TypeScript packages¶
See typescript/README.md: pnpm -C typescript test, typecheck and build, the fixture runners, and how the embedded contracts are kept in sync.