@trustloopguard/sdk
v0.0.10
Published
TypeScript SDK for TrustLoopGuard. Types are generated from Rust via tl-codegen; do not hand-edit src/generated.
Maintainers
Readme
@trustloopguard/sdk
TypeScript SDK for TrustLoopGuard runtime guardrails.
The happy path is intentionally small:
npm install @trustloopguard/sdkimport { guardAgent } from '@trustloopguard/sdk';
const agent = guardAgent(createAgent(), { agentId: 'support-agent' });
const reply = await agent.reply(userMessage);
sendToUser(reply);guardAgent(...) is the decorator. Put it where the agent is created, then
leave every agent.reply(...) and local tool call site alone.
Before you start
Create an agent and runtime API key in the TrustLoopGuard dashboard. You need:
- the registered agent ID;
- the runtime API URL;
- a runtime API key.
You do not need to clone this repository, run TrustLoopGuard locally, or configure a model-provider proxy.
1. Install One Package
npm install @trustloopguard/sdk2. Configure
Set the URL and runtime key created in the TrustLoopGuard dashboard:
export TLG_URL=https://api.gettrustloop.app
export TLG_API_KEY=tl_live_...The SDK reads these variables automatically.
3. Decorate the Agent Once
Decorate the agent object once when you create it:
import { guardAgent } from '@trustloopguard/sdk';
const agent = guardAgent(createAgent(), { agentId: 'support-agent' });
const reply = await agent.reply(userMessage);
sendToUser(reply);guardAgent(...) returns the same agent type. Existing agent.reply(...) call
sites stay unchanged. The decorator:
- discovers supported local tools from OpenAI Agents JS
agent.tools, LiveKitagent.toolCtx, MastragetToolsForExecution(), and compatible object maps; - wraps each local tool
execute()throughPOST /v1/eventsbefore the real side effect runs; - sends the exact tool name, proposed parameters, framework identity, and a stable schema identity;
- creates one
chat_sessionRun for eachreply()when no Run is already active, or reuses one session Run when a framework lifecycle is configured; - records the input as a
user_turnRun event without evaluating it; - links every guarded tool and output trace in that boundary to the Run;
- delegates to the original
reply()method; - records the proposed reply as an
assistant_turnRun event; - submits the final returned string to
POST /v1/events; - completes the automatic Run, or marks it failed when the agent throws;
- returns the original or safely transformed reply on success;
- returns a safe fallback for deny, approval, defer, and SDK failure branches;
- preserves the rest of the agent's public interface and
reply()arguments.
The decorated agent fails closed on SDK transport errors by default. Set
failClosed: false when availability is more important than enforcement.
If your app already has a helper like this:
async function generateReply(message: string): Promise<string> {
return await agent.reply(message);
}
const reply = await generateReply(userMessage);
sendToUser(reply);do not add guard checks inside the helper. Decorate the agent once before the helper sees it:
const agent = guardAgent(createAgent(), { agentId: 'support-agent' });The helper keeps working because agent.reply(...) is still the same method
from the caller's point of view.
4. Send a test reply
Keep the rest of your application unchanged:
const reply = await agent.reply('Can I receive a refund?');
sendToUser(reply);Open the resulting Run and trace in the TrustLoopGuard dashboard to verify the integration.
What happens on every wrapped call
- Your application calls
agent.reply(message). - The SDK starts a
chat_sessionRun for the configuredagentIdunless the call is already insideclient.withRun(...). - The SDK records a
user_turnevent, then the original agent generates a draft string. - The SDK records an
assistant_turnevent and sends an authenticatedPOST /v1/eventsrequest directly to the TrustLoopGuard Rust API. - The server evaluates the draft and persists a trace linked to the Run.
- The SDK completes the Run and returns the permitted draft, a transformed reply, or a safe fallback.
Automatic Run bookkeeping is best-effort and never replaces the guard result
or the agent's own error. Pass run: false to keep these traces ungrouped, or
pass run: { kind: 'workflow' } to change the automatic Run kind. Explicit
client.withRun(...) scopes remain available for multi-turn sessions and are
reused rather than nested.
Automatic Run and transcript scoping uses the isolated async context available in the SDK's supported Node.js runtime. If an unsupported browser/edge runtime cannot provide that isolation, tool/output guards still run but automatic Run and transcript capture are skipped to prevent cross-session data leakage.
Keep one Run for a LiveKit session
The default reply boundary is safe for generic agents because an agent object may serve many unrelated users. When the framework exposes a real session end, bind that lifecycle once while decorating the agent:
import { guardAgent, liveKitRun } from '@trustloopguard/sdk';
const session = createLiveKitAgentSession();
const agent = guardAgent(createAgent(), {
agentId: 'support-agent',
run: liveKitRun(session, {
externalId: roomSid,
metadata: { integrationName: 'livekit' },
}),
});
await session.start({ agent, room });The first guarded output or local tool call lazily creates one live_call Run. Later guarded activity from the same wrapped session reuses its run ID. The Run stays running until LiveKit emits close: model/session errors finish it as failed, job shutdown finishes it as canceled, and normal participant, user, or task completion finishes it as completed.
The helper is structurally typed and does not add LiveKit as an SDK dependency. Use a LiveKit room SID as externalId when available. agentId identifies the registered agent and must never be used as the customer-session key.
Other frameworks can provide the same deterministic contract directly:
const agent = guardAgent(createAgent(), {
agentId: 'support-agent',
run: {
scope: 'session',
externalId: chatSession.id,
registerEnd(finish) {
return chatSession.onEnd((outcome) =>
finish(outcome.failed ? 'failed' : 'completed'),
);
},
},
});Session Run creation and completion remain best-effort. Use onLifecycleWarning inside the run options to surface persistence failures without changing the guarded result. An explicit client.withRun scope still wins for that async boundary and is never nested.
The event is equivalent to:
POST /v1/events
Authorization: Bearer <TLG_API_KEY>
Content-Type: application/json{
"kind": "output.proposed",
"principal": {
"workspace_id": "",
"environment_id": "",
"agent_id": "support-agent"
},
"action": {
"operation": "output",
"parameters": {
"text": "<agent draft>"
},
"side_effect": "none"
},
"context": {
"channel": "chat",
"domain": "customer_support"
}
}The SDK also adds source and provenance metadata, while the server resolves workspace and environment scope from the runtime key.
With automatic Runs enabled, the raw user message and proposed assistant reply
are stored by default as user_turn.input_summary and
assistant_turn.output_summary. The user turn is transcript observability only:
it is not sent to the authorization endpoint and receives no policy decision.
The proposed assistant output and local executable tools remain guarded through
POST /v1/events before output delivery or tool execution. Pass run: false
to disable automatic Run and transcript persistence without disabling those
guards.
| Server effect | What reply() returns |
| --- | --- |
| permit | The original draft |
| transform | The server's safe transformed value |
| deny | The configured block message |
| require_approval | The configured holding message |
| defer | The configured retry-later message |
| Transport failure | A safe block by default for wrapped agents |
Agent and tool contracts
When the agent exposes reply(), its first argument must be the user message
string and it must return Promise<string>:
interface ReplyAgent {
reply(message: string, ...args: unknown[]): Promise<string>;
}For local tools, the SDK looks for an execute(input, ...context) function plus
the framework's name, description, and input schema fields. It preserves
additional execution context arguments while replacing the proposed input with
the exact parameters authorized by TrustLoopGuard.
const agent = guardAgent(
createAgent({
tools: { weatherTool, bookAppointment, sendEmail },
}),
{ agentId: 'support-agent' },
);No withAuthorizedAction(...) call is added to those three tool
implementations. The decorator installs that authorization boundary once.
OpenAI-hosted tools, remote MCP tools hidden behind a framework, and any tool
whose local execute() is not exposed cannot be intercepted before execution.
Use their host adapter or an explicit typed helper at the boundary you own.
Optional tool metadata registration
Discovery and tool-call guarding are automatic. Control-plane metadata registration is off by default because it writes workspace configuration. Enable lazy registration when the application should own that setup:
import { ToolRegistrationMode, guardAgent } from '@trustloopguard/sdk';
const agent = guardAgent(createAgent({ tools }), {
agentId: 'support-agent',
tools: {
register: ToolRegistrationMode.BestEffort,
inferMetadata(tool) {
return {
side_effect: tool.name === 'send-email' ? 'external_communication' : 'read',
reversible: false,
params: [],
};
},
onDiscoveryWarning(warning) {
logger.warn(warning);
},
},
});best_effort reports a warning and continues to authorization when
registration fails. strict stops the first tool call before authorization or
execution. Registration occurs once, lazily, before the first call to each
discovered tool.
Function-Only Integrations
If a framework exposes only a function instead of an agent object, the lower-level wrapper remains available. Use this only when there is no agent-shaped object to decorate:
import { guard } from '@trustloopguard/sdk';
const guardedReply = guard({
agentId: 'support-agent',
}).wrap(generateReply);Guard an existing draft
When input and draft are already separate values, use the callable guard:
import { guard } from '@trustloopguard/sdk';
const protect = guard({
agentId: 'support-agent',
});
const reply = await protect({
input: userMessage,
draft: agentDraft,
});Guard modes
| Mode | Behavior |
|------|----------|
| strict | Blocked or transformed output is rejected |
| rewrite | Uses the safe transformed output and blocks when none exists |
| rewrite_or_regenerate | Uses the transformed output or invokes your regeneration callback |
import { GuardMode, guardAgent } from '@trustloopguard/sdk';
const agent = guardAgent(createAgent(), {
agentId: 'support-agent',
mode: GuardMode.Rewrite,
});Custom branch messages remain optional:
const agent = guardAgent(createAgent(), {
agentId: 'support-agent',
onBlock: "I can't help with that.",
onRequireApproval: 'A human must approve this response.',
onDefer: 'I need more verified information before continuing.',
});Streaming output
Token streams must be buffered before any unguarded output is delivered:
const protect = guard({ agentId: 'support-agent' });
const reply = await protect.stream({
input: userMessage,
draft: modelTokenStream,
});Explicit action helpers
Use withAuthorizedAction when a framework does not expose its local tool
registry, when tools are created dynamically outside the decorated agent, or
when the caller must attach explicit provenance. Payments and typed financial
actions remain on their dedicated helpers.
Requirements
- Node.js 22+
- TypeScript 5+ recommended
Troubleshooting
- No trace appears: check
TLG_URL,TLG_API_KEY, and thatagentIdmatches the dashboard agent. 401 Unauthorized: create or copy a runtime key for the same workspace and environment as the agent.- Your framework does not expose
reply(): Promise<string>: local tools can still be discovered, but guard the final framework result with the function-only.wrap()form. - A hosted or hidden tool was not wrapped: provide
tools.onDiscoveryWarning, then use the framework host adapter orwithAuthorizedActionfor that boundary. - Streaming: buffer the complete response with
protect.stream(...)before sending any tokens to the user.
License
Apache-2.0
