@cygnuxlabs/tracekit
Experimental: the client and its Vercel AI SDK integration work and are tested, but may change and get less hardening than the stable core. Its verifier (
src/verify/) is stable.
Tracekit for TypeScript and JavaScript agents: every tool call is decided by the Tracekit signer before it runs and recorded after, and model responses become signed evidence. The client talks to the signer directly; it needs no Python at runtime (the same-user dev signer is started with Python when none is running).
Install
Node 18 or newer:
npm install @cygnuxlabs/tracekit
npm also installs the signer package for your platform (@cygnuxlabs/tracekit-signer-<platform>: Apple Silicon
macOS, Linux x64 and arm64 with glibc, Windows x64), which bundles a Python, so npx @cygnuxlabs/tracekit up starts a
dev signer without one. On an Intel Mac or Alpine there is none: pip install tracekit-ai.
Or build it from a checkout and install the file it makes:
cd sdk/typescript && npm ci && npm run build && npm pack # writes cygnuxlabs-tracekit-<version>.tgz
cd /path/to/your/app && npm install /path/to/Tracekit/sdk/typescript/cygnuxlabs-tracekit-*.tgz
Signer client (@cygnuxlabs/tracekit)
A native client of the v2 signer RPC with no runtime dependencies (Node ≥ 18 built-ins only, no Python bridge); also
exported as @cygnuxlabs/tracekit/v2.
import { Client, withRun, currentRun } from "@cygnuxlabs/tracekit";
const client = new Client(); // or new Client({ signer: "https://signer:8443" })
const run = await client.registerRun("research-bot");
const d = await run.decide(call.id, "Bash", call.function.arguments); // the raw model string, or a parsed value
if (d.decision === "allow") {
if ((await run.approvalConsume(call.id, "Bash", call.function.arguments)).ok) // never throws: ok false means it doesn't run
await run.complete(call.id, "ok", { result: await runShell(call) }); // never throws: a recording failure is a warning
} else if (d.decision === "ask") {
const { approval_id } = await run.approvalRequest(call.id);
const { state } = await run.approvalWait(approval_id, 300_000); // on a connection of its own
if (state === "approved" && (await run.approvalConsume(call.id, "Bash", call.function.arguments, approval_id)).ok)
await run.complete(call.id, "ok", { result: await runShell(call) });
}
await run.close();
await client.close(); // waits for what is in flight
- Signer:
$TRACEKIT_SIGNERnames a Unix socket,tcp://127.0.0.1:port(with$TRACEKIT_SIGNER_TOKEN; both sides prove they hold it, it never goes on the wire) orhttps://host:port($TRACEKIT_SIGNER_TOKEN_FILE: a bearer token re-read per call;$TRACEKIT_SIGNER_CERT/_KEY: mTLS;$TRACEKIT_SIGNER_CA: the signer's CA). In system mode the signer named by the root-owned/etc/tracekit/client.jsonis used and a different$TRACEKIT_SIGNERis refused. Otherwise the same-user dev signer is found, or started withtracekit up --json:$TRACEKIT_PYTHON -m tracekitif set, else a pip-installedtracekiton PATH, else the Python bundled in this platform's@cygnuxlabs/tracekit-signer-<platform>package.npx @cygnuxlabs/tracekit up|down|status|view|verify|signerruns the same command. - Protocol:
hellofirst; a signer that does not speak this client's RPC version is refused withIncompatible. Requests are validated against the RPC schemas before they are sent, pipelined on one connection and answered in order; a call whose connection drops is resent with the samerequest_id. Each handle's events carry the client'sstreamand aclient_seqper run. - Fail modes: only when the signer cannot be reached does
decidereturn the run's fail mode for the tool class (fields.tool_class_hint, fromregister_run'sfail_modes, default closed) withunavailable: true. A refusal from the signer rejects withRPCError, never an allow. - Serverless:
client.waitUntil(promise)andawait client.flush()before the host freezes the process. withRun(run, fn)makesrunthecurrentRun()of everythingfnawaits (AsyncLocalStorage).argsDigest(tool, args),canonicalize,strictParse: JCS (RFC 8785) and the strict parser, byte-identical to the Python client on the shared vectors intests/vectors/jcs.jsonl.
Verify a bundle (@cygnuxlabs/tracekit/verify)
An independent verifier of v2 bundles (.tkb), written from docs/format-v2.md and tested to
agree with the Python verifier. No dependencies: Web Crypto and DecompressionStream, so Node ≥ 20.12 and current
browsers.
npx @cygnuxlabs/tracekit verify run.tkb --trust trust.json [--json] [--strict]
Exit 0 verified, 1 failed, 2 unusable or unverifiable, 3 a warning under --strict; the report and --json are those
of tracekit verify. In code, Node or a browser:
import { verify, formatReport } from "@cygnuxlabs/tracekit/verify";
const bundle = new Uint8Array(await (await fetch("run.tkb")).arrayBuffer());
const trust = new Uint8Array(await (await fetch("trust.json")).arrayBuffer()); // your own pinned trust config
const { report, code } = await verify(bundle, trust);
console.log(report.integrity, report.assurance); // e.g. "VERIFIED", "witnessed; records ed25519; ..."
It does not check SLH-DSA checkpoint lines, Rekor anchors or certified record keys: a bundle that relies on one is
UNVERIFIABLE (not checked by this verifier: ...), never VERIFIED. Monitor reports, revocations and the v1 format
bridge need the Python verifier.
verify(bundle, null), with no trust config, runs every check that needs no pinned key and is never VERIFIED: a
changed record still fails, an intact bundle is UNVERIFIABLE (no trust config: ...).
In a browser: dist/verify.html in this package (and a release asset) is this verifier as one self-contained page.
Drop in a bundle and its trust config; its Content Security Policy lets it load and send nothing, so neither file
leaves the browser (verify in your browser). make verify-page builds
it from a checkout.
v2 framework adapters
One call registers a run (the dev signer is started if none answers), closes it when the process is about to exit,
and hands you the adapters bound to it; then tracekit last exports, pins and verifies the run:
import { instrument } from "@cygnuxlabs/tracekit";
const tk = await instrument(); // the same on a second call
const model = wrapLanguageModel({ model: openai("gpt-5"), middleware: tk.ai.middleware }); // Vercel AI SDK
const r = await generateText({ model, tools: tk.ai.tools({ pay }), toolApproval: tk.ai.toolApproval, messages });
const agent = new Agent({ name: "payer", tools: [tk.agents.tool(tool, { name: "pay", description, parameters, execute })] }); // OpenAI Agents JS
ES modules can't be patched from outside, so unlike Python's tracekit.instrument() the adapters are passed to the
framework by you.
Each adapter gates every tool call through the signer: it passes the call id, its attempt and the model's raw
arguments, the signer decides, an ask waits for a person (held in the hook where the framework allows, else paused
and resumed), and every call, allowed or approved, runs only after the signer's approval_consume agrees. A deny
reaches the model as the tool's error result and the run goes on. The run's fail mode (register_run's fail_modes)
applies only when the signer cannot be reached at the decision; a refusal, or a failure while waiting for an approval,
never lets the call run. Recording a finished call never throws and never changes its result (a failure is a
TracekitWarning). The frameworks are optional peer dependencies: the adapters import none of them, and the client
keeps no runtime dependencies. No adapter wraps a provider client, so the SDKs' own APIPromise (.withResponse())
and streams (messages.stream(), streamText) work as without Tracekit. Offline examples: examples/v2_*.mjs, and
a Mastra workflow that suspends on a held call and resumes with its approval id.
- A server: a run per conversation. Register a run per conversation and make it current around each request with
withRun(run, () => runner.run(...)): every adapter decides the calls made inside it in that run (its complete and approvals follow the call), and uses the run it was built with otherwise.instrument()'s own run is replaced by a new one (a warning, linked asparent_runwhile the old one is closing) when the signer closed it after it was idle. - Exit.
instrument()closes its run onbeforeExit, and on SIGINT or SIGTERM when nothing else handles the signal (then the signal's default action runs). A signal your app or a library handles is left to it (@openai/agentsexits on SIGINT/SIGTERM): close the run in your own handler (await tk.run.close()) before you exit. A run stays open while approvals of it wait for a person or, approved, to be resumed. To approve and resume in another process, keep the run'srunIdandrunTokenwith the framework's saved state and re-attach:new RunHandle(new Client(), { run_id, run_token }); close that client when you are done with it (run.client.close()), or re-attach every run through one shared client.process.exit()skips all of this: close first. The offline Vercel AI resume example saves the run and messages, prints the approval command, and resumes in a second process. - The LLM gateway. Point the provider at it with the run in a header, e.g. OpenAI Agents
setDefaultOpenAIClient(new OpenAI({ baseURL, apiKey: <gateway token>, defaultHeaders: { "X-Tracekit-Run":${run.runId}.${run.runToken}} })), Vercel AIcreateOpenAI({ baseURL, apiKey, headers }). Register the run as the identity the gateway relays (TRACEKIT_SIGNER=https://…with the same token), and grant that identity the RPCs the adapters use,approval_listincluded (limits). The adapters' own model events are then optional: the gateway records each exchange too.
OpenAI Agents SDK (@cygnuxlabs/tracekit/v2/openai-agents)
import { Agent, Runner, tool } from "@openai/agents";
import { TracekitAgents } from "@cygnuxlabs/tracekit/v2/openai-agents";
const tk = new TracekitAgents(await new Client().registerRun("payer"));
const agent = new Agent({ name: "payer", tools: [tk.tool(tool, { name: "pay", description, parameters, execute })] });
let result = await runner.run(agent, "pay acct-42 $15", { context: {} }); // the context must be an object
if (result.interruptions.length) { // an `ask`: once decided in the signer
await tk.applyDecisions(result.state);
result = await runner.run(agent, result.state);
}
await tk.recordResponses(result.rawResponses, "gpt-5");
tk.tool(tool, options) builds the tool with the SDK's tool(): the signer's policy replaces needsApproval, and a
tool input guardrail (after your own) gates the call on its raw arguments. The approval id travels in the RunState only
as a hint (context.tracekit.approvals); the signer checks it. Leave toolExecution.preApprovalInputGuardrails off:
with it on, the guardrail runs before the interruption and every ask is refused at once.
A local MCP server (MCPServerStdio, SSE, streamable HTTP) is gated through its tool guardrails:
new MCPServerStdio({ name: "filesystem", ..., ...tk.mcp("filesystem") }) decides each call as
mcp:filesystem/<tool>, holds an ask in the guardrail until a person decides, and completes the call with the
server's output. Pass the server's own name: with mcpConfig.includeServerInToolNames the model's
mcp_<server>__<tool> is turned back into mcp:<server>/<tool> from it. A call your own input guardrails refuse is
recorded as refused. Without it, local MCP calls
are not gated. A sub-agent used as a tool (agent.asTool()) is not gated either, and its model responses are not in
the outer rawResponses: gate its own tools with tk.tool, and record its responses (customOutputExtractor gets
the nested result) or run the LLM gateway. Also not gated, listed as uncovered: hosted tools (web and file search,
code interpreter, hosted MCP, hosted shell), shell, apply_patch and computer tools, and handoffs (name them in the
policy's handoffs: so they reconcile). recordResponses records them (T3) in a signed model event per response.
Vercel AI SDK v7 (@cygnuxlabs/tracekit/v2/vercel-ai)
import { generateText, wrapLanguageModel } from "ai";
import { tracekitAI } from "@cygnuxlabs/tracekit/v2/vercel-ai";
const tk = tracekitAI(await new Client().registerRun("assistant"));
const model = wrapLanguageModel({ model: openai("gpt-5"), middleware: tk.middleware });
const r = await generateText({ model, tools: tk.tools({ pay }), toolApproval: tk.toolApproval, messages });
// asks end the step with tool-approval-request parts; once decided in the signer:
messages.push(...r.response.messages, { role: "tool", content: await tk.approvalResponses(r.content) });
The same options work for streamText and ToolLoopAgent. tk.middleware records each model response (V3 usage,
the tool calls asked for) and keeps each call's raw arguments by toolCallId for the decision. tk.toolApproval maps
deny to denied and ask to user-approval; without it an ask is refused. tk.tools() wraps each execute, so a
forged or replayed tool-approval-response runs nothing. experimental_toolApprovalSecret (the SDK's HMAC over its own
approval requests) may be set too; it does not replace the signer's check. Spans from @ai-sdk/otel are telemetry, not
evidence: the signed records are the middleware's and the adapter's. Tools without execute and provider-executed tools
are not gated; they appear in the recorded model responses.
MCP tools (@ai-sdk/mcp): tk.tools(await client.tools(), { mcp: "filesystem" }) decides each as
mcp:filesystem/<tool>. Your own toolApproval (a function, or one per tool name) composes with the signer's:
toolApproval: tk.toolApprovalWith(mine) asks the policy first, and your answer applies only to a call the policy
allows (a call you deny is recorded as refused; one you answer user-approval and then deny in your own UI stays
decided and not completed); tk.toolApproval alone replaces yours.
Claude Agent SDK (@cygnuxlabs/tracekit/v2/claude-agent-sdk)
import { query } from "@anthropic-ai/claude-agent-sdk";
import { tracekitHooks, tracekitSessionStore } from "@cygnuxlabs/tracekit/v2/claude-agent-sdk";
const run = await new Client().registerRun("my-agent");
query({ prompt, options: { hooks: tracekitHooks(run), sessionStore: tracekitSessionStore(myStore, run) } });
The gate is the PreToolUse hook (it runs for every call; canUseTool is skipped for calls the CLI's permission mode
allows). An ask is held in the hook for up to 540 s, below the hook timeout it sets (600 s); a call not approved by
then is blocked. PostToolUse/PostToolUseFailure complete the call, SessionEnd closes the run. The session store
wrapper commits each saved transcript as a state_write chained from the last one, so a transcript edited in the store
between two writes shows up as a signed state_tamper gap.
Migrating from v1
The v1 SDK (Tracekit, driving python -m tracekit.bridge) is deprecated and removed in 0.5.0. Until then it stays at
@cygnuxlabs/tracekit/v1; Tracekit.start warns once per process (DeprecationWarning, code TRACEKIT_V1) and the bridge
prints a matching notice on stderr; TRACEKIT_NO_DEPRECATION=1 silences both. The import becomes
import { Tracekit } from "@cygnuxlabs/tracekit/v1".
v1 (@cygnuxlabs/tracekit/v1) | v2 (@cygnuxlabs/tracekit) |
|---|---|
await Tracekit.start({ agent }) | const client = new Client(); const run = await client.registerRun(agent) |
await tk.tool(name, args, fn, { toolUseId }) | await run.decide(toolUseId, name, args), run fn on allow, then await run.complete(toolUseId, "ok", { result }) |
TracekitDenied | d.decision === "deny" (d.rule_ids); a refusal by the signer rejects with RPCError |
held for approval inside tk.tool | d.decision === "ask": run.approvalRequest(id), then run.approvalWait(approval_id) |
instrumentOpenAI / instrumentAnthropic, tk.modelBegin / tk.modelEnd | run.modelEvent(provider, model, "response", { stop_reason, usage, tool_uses }), or a framework adapter below |
tracekitMiddleware(tk) | tracekitAI(run).middleware from @cygnuxlabs/tracekit/v2/vercel-ai, with tools and toolApproval |
tk.prompt / tk.say / tk.think | not recorded by the v2 signer |
tk.end(reason) | await run.close(reason); await client.close() |
// v1
import { Tracekit, TracekitDenied } from "@cygnuxlabs/tracekit/v1";
const tk = await Tracekit.start({ agent: "research-bot" });
try {
out = await tk.tool("Bash", args, () => runShell(args), { toolUseId: call.id });
} catch (e) {
if (!(e instanceof TracekitDenied)) throw e;
}
await tk.end();
// v2
import { Client } from "@cygnuxlabs/tracekit";
const client = new Client();
const run = await client.registerRun("research-bot");
const d = await run.decide(call.id, "Bash", call.function.arguments);
if (d.decision === "allow") {
out = await runShell(args);
await run.complete(call.id, "ok", { result: out });
}
await run.close();
await client.close();
v1 SDK (deprecated, @cygnuxlabs/tracekit/v1)
Removed in 0.5.0; see the migration above. Tool calls pass Tracekit's policy gate before they run, and model calls become signed evidence, in the same ledger and format as the Python SDK.
import OpenAI from "openai";
import { Tracekit, TracekitDenied, instrumentOpenAI } from "@cygnuxlabs/tracekit/v1";
const tk = await Tracekit.start({ agent: "research-bot" });
const openai = instrumentOpenAI(new OpenAI(), tk); // also: instrumentAnthropic(client, tk), tracekitMiddleware(tk) for the Vercel AI SDK
const r = await openai.chat.completions.create({ model: "gpt-4o", messages, tools });
for (const call of r.choices[0].message.tool_calls ?? []) {
await tk.tool("Bash", JSON.parse(call.function.arguments), () => runShell(call), { toolUseId: call.id }); // TracekitDenied if blocked
}
await tk.end();
tk.tool(name, args, fn): the policy decision is made and signed beforefnruns; a denied (or held, not approved) call throwsTracekitDeniedandfnnever runs. Pass the model's call id astoolUseIdto link request and execution.instrumentOpenAI(chat completions, Responses),instrumentAnthropic(messages): a signed request event before the call is sent, and a response event with model, finish reason, requested tool calls, token usage, error, status and time to first chunk. Streams are recorded when they end, fail or are abandoned: the SDK replaces the stream's async iterator in place with a recording one; non-streamed responses come back as the provider SDK returned them. The wrappedcreateis an async function, so it returns a plain Promise: helpers on the SDK's own promise object (such as.withResponse()) are not available through it. A provider error is recorded and rethrown. The request event is written before the call is sent: if the bridge reports an error or times out, that error is thrown and the call is not sent; if the bridge has exited,fail_mode: closedthrowsTracekitDeniedandfail_mode: opensends the call unrecorded with a warning on stderr. A failure to record a call that already completed is written to stderr and counted intk.recordFailures; it never replaces the call's result.tracekitMiddleware(tk):wrapLanguageModel({ model, middleware: tracekitMiddleware(tk) })in the Vercel AI SDK (v4 and v5 result shapes).
How it works. The SDK starts python -m tracekit.bridge and talks to it over stdio, so policy evaluation, secret
redaction, the signer client and the event format are Tracekit's own Python code, not a second implementation. It needs
the tracekit Python package and a signer (tracekit init --dev); set TRACEKIT_PYTHON to pick the interpreter.
Tests (npm test): test/sdk.test.mjs runs the v1 SDK with the real OpenAI and Anthropic SDKs against a mocked fetch and a real signer, then verify the
bundle; test/adapters.test.mjs runs the adapter contract for each adapter, through small stand-ins for the
frameworks' hook and middleware interfaces, against an in-test fake signer and (with Python and the signer extra) the
real one.