TnsAI
Core

Security & Approvals

TnsAI provides a layered security model for agent actions: approval tokens for human-in-the-loop gating, AG-UI interrupts for blocking agent execution until a user responds, input/output guardrails for validation and sanitization, and a declarative `@Security` annotation that combines audit, access control, and encryption policies in one place.

@ApprovalRequired and ApprovalToken

Some actions are too dangerous to run automatically -- deleting data, sending emails, or making payments. The @ApprovalRequired annotation marks these actions so the framework blocks execution until a human explicitly approves it through an ApprovalToken. This is the foundation of human-in-the-loop safety in TnsAI.

Annotation

Place @ApprovalRequired on any action method that should not run without human consent. The reason field is shown to the approver so they understand why approval is needed.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ApprovalRequired {
    String reason() default "Sensitive operation";
}

Usage:

@ActionSpec(type = ActionType.LOCAL, description = "Delete user account permanently")
@ApprovalRequired(reason = "Permanent data deletion")
public void deleteUser(String userId) {
    // Only runs if an approved token is presented
}

ApprovalToken

An ApprovalToken is a time-limited, agent-and-action-scoped credential that tracks the approval state. It is created when a protected action is invoked, sent to a human for review, and then approved or rejected. Tokens are bound to a specific agent and action, time-limited, and optionally single-use. The lifecycle is:

  1. Action annotated with @ApprovalRequired is invoked.
  2. Token created with agentId, actionName, and expiry.
  3. Token sent to an approval system (UI, CLI, API).
  4. Human approves or rejects.
  5. If approved, the action executes.
  6. If singleUse, the token transitions to CONSUMED.

Status Enum

A token moves through these states during its lifecycle. Once it leaves PENDING, it cannot go back.

StatusDescription
PENDINGAwaiting human decision
APPROVEDApproved by human
REJECTEDRejected by human
EXPIREDTTL elapsed before decision
CONSUMEDSingle-use token already used

Builder

Create tokens with the builder for full control over expiration, single-use behavior, and metadata. The agentId and actionName fields scope the token so it cannot be reused for a different action.

ApprovalToken token = ApprovalToken.builder()
    .agentId("agent-1")
    .actionName("delete-user")
    .parameters(Map.of("userId", "123"))
    .timeToLive(Duration.ofMinutes(5))   // default: 5 minutes
    .singleUse(true)                     // default: true
    .requestedBy("system")
    .reason("User requested account deletion")
    .tokenId("custom-id")               // optional, auto-generated UUID if omitted
    .build();

Factory shorthand:

ApprovalToken token = ApprovalToken.create("agent-1", "delete-user", Duration.ofMinutes(5));

State Mutations

These methods transition the token between states. Each returns true if the transition was valid, false if it was not (for example, trying to approve an already-expired token).

// Approve -- only valid (not expired/consumed/rejected) tokens can be approved
boolean ok = token.approve("admin@example.com");

// Reject -- only PENDING, non-expired tokens can be rejected
boolean ok = token.reject("admin@example.com", "Too risky");

// Consume -- marks single-use tokens as CONSUMED after execution
boolean ok = token.consume();

Query Methods

Use these to check the current state of a token. The isValid() method is a convenient shorthand that checks the token is not expired, consumed, or rejected.

token.isApproved();        // status == APPROVED
token.isPending();         // status == PENDING (auto-checks expiry)
token.isRejected();        // status == REJECTED
token.isExpired();         // Instant.now().isAfter(expiresAt)
token.isConsumed();        // status == CONSUMED
token.isValid();           // !expired && !consumed && !rejected
token.getRemainingTime();  // Duration until expiry (Duration.ZERO if past)
token.matches("agent-1", "delete-user"); // checks agentId + actionName

ApprovalTokenStore

In a real application, tokens need to be stored so they survive between requests and can be looked up when the human responds. ApprovalTokenStore is the persistence interface, and three implementations ship with TnsAI for different deployment scenarios.

ImplementationUse case
InMemoryApprovalTokenStoreDevelopment and testing
RedisApprovalTokenStoreDistributed systems
DatabaseApprovalTokenStoreSQL-backed persistent storage

Store API

The store manages the full token lifecycle: creating, saving, looking up, approving, rejecting, validating, and cleaning up tokens.

ApprovalTokenStore store = new InMemoryApprovalTokenStore();

// Create tokens
ApprovalToken token = store.createToken("agent-1", "delete-data", Duration.ofMinutes(5));
ApprovalToken token = store.createToken("agent-1", "delete-data",
    Map.of("userId", "123"), Duration.ofMinutes(5));

// Or save a manually built token
store.save(token);

// Lookup
Optional<ApprovalToken> found = store.getToken(tokenId);

// Approve / Reject via store
store.approve(tokenId, "admin@example.com");
store.reject(tokenId, "admin@example.com", "Not authorized");

// Validate and consume atomically -- checks approved + not expired + agent/action match
boolean valid = store.validateAndConsume(tokenId, "agent-1", "delete-data");

// List pending tokens
List<ApprovalToken> pending = store.listPending();
List<ApprovalToken> agentPending = store.listPendingForAgent("agent-1");

// Cleanup
int cleaned = store.cleanupExpired();
boolean deleted = store.delete(tokenId);
int total = store.count();

Full Workflow Example

This example shows the complete approval flow from token creation to action execution, as it would work in a web application with an approval UI.

// 1. Create and store token
ApprovalTokenStore store = new InMemoryApprovalTokenStore();
ApprovalToken token = store.createToken("agent-1", "delete-user", Duration.ofMinutes(5));

// 2. Send token ID to approval UI (REST endpoint, WebSocket, etc.)
sendToApprovalUI(token.getTokenId(), token.getActionName(), token.getReason());

// 3. Human approves via UI -> callback hits server
store.approve(token.getTokenId(), "admin@example.com");

// 4. Action executor validates before running
boolean canExecute = store.validateAndConsume(
    token.getTokenId(), "agent-1", "delete-user"
);

if (canExecute) {
    executeAction();
}

InterruptService (AG-UI Pattern)

When your agent runs in a UI-connected environment, you may want to pause execution mid-task and ask the user a question before continuing. InterruptService implements this pattern: it blocks the agent thread, emits an event to the frontend, waits for the user's response, and then resumes the agent with the result.

Flow

Here is the step-by-step sequence of how an interrupt works, from the agent requesting it to the user responding and the agent resuming.

  1. Agent calls requestInterrupt() before a critical action.
  2. Service creates an interrupt; the agent thread blocks.
  3. AG-UI emits RUN_FINISHED { outcome: "interrupt", interrupt: {...} }.
  4. Frontend shows an approval dialog.
  5. User approves or rejects.
  6. Frontend POSTs to /v1/runs/{runId}/resume.
  7. Service completes the future; the agent thread unblocks.
  8. Agent continues or aborts based on the result.

Constructors

Create an InterruptService with an optional listener (which emits events to the frontend) and an optional default timeout for how long to wait for a human response.

// Default: no listener, 5-minute timeout
InterruptService service = new InterruptService();

// With listener for AG-UI event emission
InterruptService service = new InterruptService(request -> {
    agUiAdapter.emitInterrupt(request);
});

// With listener and custom default timeout
InterruptService service = new InterruptService(listener, Duration.ofMinutes(10));

The listener receives an InterruptRequest record:

public record InterruptRequest(
    String interruptId,
    InterruptReason reason,
    Map<String, Object> payload,
    Duration timeout,
    Instant createdAt
) {}

InterruptReason Enum

The reason tells the frontend what kind of interrupt this is, so it can show the appropriate UI (an approval dialog, a file upload prompt, an error recovery form, etc.).

ValueAG-UI wire valueUse case
HUMAN_APPROVAL"human_approval"Sensitive actions (delete, send, pay)
UPLOAD_REQUIRED"upload_required"Missing files or parameters
POLICY_VIOLATION"policy_violation"Rate limit, budget, access control override
ERROR_RECOVERY"error_recovery"API errors needing human guidance
CONFIRMATION"confirmation"Multi-step wizard confirmation
CUSTOM"custom"Anything else

Parsing: InterruptReason.fromValue("human_approval") returns HUMAN_APPROVAL. Unknown strings return CUSTOM.

Requesting Interrupts

There are two ways to request an interrupt: blocking (the agent thread waits) and non-blocking (you get a CompletableFuture).

Blocking (synchronous) -- blocks the calling thread until resolved or timed out:

InterruptResult result = service.requestInterrupt(
    InterruptReason.HUMAN_APPROVAL,
    Map.of("action", "delete_user", "userId", "123")
);
// Thread blocks here until user responds or timeout

if (result.shouldProceed()) {
    deleteUser("123");
} else {
    throw new ActionRejectedException(result.getRejectionReason());
}

With custom timeout:

InterruptResult result = service.requestInterrupt(
    InterruptReason.HUMAN_APPROVAL,
    Map.of("action", "delete_user", "userId", "123"),
    Duration.ofMinutes(10)
);

Non-blocking (async) -- returns a CompletableFuture:

CompletableFuture<InterruptResult> future = service.requestInterruptAsync(
    InterruptReason.CONFIRMATION,
    Map.of("summary", orderSummary),
    Duration.ofMinutes(5)
);

future.thenAccept(result -> {
    if (result.isApproved()) {
        submitOrder();
    }
});

Resuming and Cancelling

These methods are called from your backend endpoint when the user responds in the frontend. The resume call unblocks the waiting agent thread with the user's decision.

// Approve
service.resume(interruptId, true, Map.of("approver", "admin"));

// Reject
service.resume(interruptId, false, Map.of("reason", "Too risky"));

// Cancel programmatically
service.cancel(interruptId);

Query Methods

Check whether an interrupt is still waiting for a response, retrieve its details, or count all active interrupts.

service.isPending(interruptId);                 // true if active and not expired
Optional<InterruptRequest> req =
    service.getPendingInterrupt(interruptId);    // details or empty
int count = service.getPendingCount();           // auto-cleans expired

InterruptResult

When an interrupt resolves, the agent receives an InterruptResult that tells it the outcome: approved, rejected, timed out, cancelled, or error. Use shouldProceed() as the primary check for whether to continue with the action.

Outcome Enum

These are the possible outcomes of an interrupt. Only APPROVED results in shouldProceed() returning true.

OutcomeDescription
APPROVEDUser approved the action
REJECTEDUser rejected the action
TIMED_OUTNo response within timeout
CANCELLEDCancelled programmatically
ERRORAn exception occurred

Factory Methods

Create InterruptResult instances for each outcome. These are typically called by the InterruptService internally, but you can use them in tests.

InterruptResult.approved(interruptId, Map.of("approver", "admin"));
InterruptResult.rejected(interruptId, Map.of("reason", "Not safe"));
InterruptResult.timedOut(interruptId);
InterruptResult.cancelled(interruptId);
InterruptResult.error(interruptId, "Connection failed");

Query Methods

These methods let you inspect the result and extract relevant details like the approver's identity or rejection reason.

result.isApproved();         // outcome == APPROVED
result.isRejected();         // outcome == REJECTED
result.isTimedOut();         // outcome == TIMED_OUT
result.isCancelled();        // outcome == CANCELLED
result.isError();            // outcome == ERROR
result.shouldProceed();      // true only if APPROVED

result.getInterruptId();     // the interrupt ID
result.getOutcome();         // Outcome enum value
result.getPayload();         // Map<String, Object>, never null
result.getErrorMessage();    // error message (null unless ERROR)
result.getRejectionReason(); // payload.get("reason") as String, null if not rejected
result.getApprover();        // payload.get("approver") as String

@InputGuardrail

Before an action runs, you want to make sure the input is safe and well-formed. @InputGuardrail lets you declare validation rules (length limits, required patterns, injection detection) and sanitization steps (trimming whitespace, escaping HTML) that are applied automatically before the action method is called.

Fields

These fields control what validation and sanitization is applied to the input. Most fields are optional -- only configure what you need.

FieldTypeDefaultDescription
maxLengthint0Maximum input length in characters. 0 = no limit.
minLengthint0Minimum input length in characters.
validatorsClass<?>[]{}Validator classes (must implement InputValidator).
sanitizersClass<?>[]{}Sanitizer classes applied in order (must implement InputSanitizer).
blockPatternsString[]{}Regex patterns to reject (prompt injection protection).
allowPatternsString[]{}Regex patterns input must match. Non-matching input is rejected.
jsonSchemaString""JSON schema path or inline schema for JSON input validation.
detectInjectionbooleantrueEnable prompt injection detection.
injectionThresholddouble0.7Injection detection sensitivity (0.0--1.0). Higher = stricter.
moderateContentbooleanfalseEnable content moderation.
blockCategoriesContentCategory[]{}Content categories to block.
onFailureFailureActionREJECTWhat to do when validation fails.
errorMessageString""Custom error message on failure.
logFailuresbooleantrueLog validation failures.

ContentCategory Enum

When content moderation is enabled, you can block specific categories of harmful content. These match standard content moderation taxonomies.

HATE, VIOLENCE, SEXUAL, SELF_HARM, HARASSMENT, DANGEROUS, ILLEGAL

FailureAction Enum

When validation fails, this determines what happens next. Choose based on how strict your use case requires.

ValueBehavior
REJECTReject input and throw exception
SANITIZESanitize the input and continue
WARNLog a warning and continue
REVIEWRequest human review

Built-in Validators

TnsAI ships with these validators out of the box. You can also implement your own by implementing the InputValidator interface.

NotEmpty, MaxLength, NoInjection, SafeContent, JsonSchema

Built-in Sanitizers

These sanitizers transform the input to remove or escape potentially harmful content. They are applied in the order you list them.

TrimWhitespace, HtmlEscape, NormalizeUnicode, RemoveControlChars

Example

@ActionSpec(type = ActionType.LOCAL, description = "Process user message")
@InputGuardrail(
    maxLength = 10000,
    validators = {NotEmpty.class, NoInjection.class},
    sanitizers = {TrimWhitespace.class, HtmlEscape.class},
    blockPatterns = {"ignore previous", "system prompt"},
    injectionThreshold = 0.8,
    onFailure = FailureAction.REJECT
)
public String processMessage(String message) {
    // message is validated and sanitized before this runs
}

Role-level default:

@InputGuardrail(
    maxLength = 5000,
    validators = {NotEmpty.class},
    detectInjection = true
)
public class UserInputRole extends Role {
    // All actions in this role inherit these guardrails
}

@OutputGuardrail

Just as you validate input before processing, you should validate output before returning it to the user. @OutputGuardrail lets you enforce format requirements, mask personally identifiable information (PII), check for hallucinations, and moderate content -- all declaratively through an annotation.

Fields

These fields control output validation, PII masking, hallucination detection, and what to do when validation fails.

FieldTypeDefaultDescription
formatOutputFormatTEXTExpected output format.
schemaString""JSON schema path or inline schema.
maxTokensint0Maximum output length in tokens. 0 = no limit.
maxCharsint0Maximum output length in characters.
minCharsint0Minimum output length in characters.
validatorsClass<?>[]{}Validator classes (must implement OutputValidator).
postProcessorsClass<?>[]{}Post-processor classes (must implement OutputParser).
maskPIIbooleanfalseEnable PII detection and masking.
piiTypesPIIType[]{}PII types to mask.
moderateContentbooleanfalseEnable content moderation on output.
blockCategoriesContentCategory[]{}Content categories to filter (reuses InputGuardrail.ContentCategory).
checkHallucinationbooleanfalseCheck for hallucinations (requires context).
hallucinationThresholddouble0.8Hallucination confidence threshold (0.0--1.0).
onFailureFailureActionRETRYWhat to do when validation fails.
maxRetriesint3Maximum retry attempts on failure.
fallbackString""Fallback value if all retries fail.
logFailuresbooleantrueLog validation failures.
includeSpecbooleanfalseInclude validation metadata in response.

OutputFormat Enum

The expected format of the action's output. When set, the framework validates that the output conforms to this format.

TEXT, JSON, MARKDOWN, HTML, XML, YAML, CSV

PIIType Enum

When PII masking is enabled, specify which types of personal information to detect and redact from the output.

EMAIL, PHONE, SSN, CREDIT_CARD, ADDRESS, NAME, DOB, IP_ADDRESS, PASSPORT, DRIVER_LICENSE

FailureAction Enum

When output validation fails, this determines the recovery behavior. RETRY is the default because LLMs often produce valid output on the second attempt.

ValueBehavior
RETRYRetry generation
FALLBACKReturn fallback value
REJECTThrow exception
TRUNCATEReturn partial/truncated output
REVIEWRequest human review

Built-in Validators

TnsAI ships with these output validators. You can also implement the OutputValidator interface for custom checks.

NotEmpty, JsonValid, SchemaCompliant, NoHallucination, NoPII, SafeContent

Example

@ActionSpec(type = ActionType.LLM_GENERATION, description = "Generate response")
@OutputGuardrail(
    format = OutputFormat.JSON,
    schema = "response-schema.json",
    maxTokens = 1000,
    validators = {NoHallucination.class, NoPII.class},
    maskPII = true,
    piiTypes = {PIIType.EMAIL, PIIType.PHONE, PIIType.SSN},
    onFailure = FailureAction.RETRY,
    maxRetries = 3,
    fallback = "{\"error\": \"Unable to generate response\"}"
)
public String generateResponse(String query) {
    // Response is validated before returning to the caller
}

Role-level default:

@OutputGuardrail(
    format = OutputFormat.MARKDOWN,
    maxTokens = 2000,
    checkHallucination = true
)
public class ContentRole extends Role {
    // All actions inherit these output guardrails
}

@Security

For actions that need multiple security controls at once, @Security bundles everything into a single annotation: approval gating, audit logging, access control (who can call this action), encryption of parameters and results, and idempotency protection. This avoids the need to stack multiple separate annotations on the same method.

Fields

Each field controls one aspect of the security policy. All fields are optional with safe defaults -- only enable what your action needs.

FieldTypeDefaultDescription
approvalRequiredbooleanfalseRequire human approval before execution.
approvalReasonString""Reason shown to the approver.
auditAuditLevelNONEAudit logging level.
sensitivebooleanfalseMarks action as handling sensitive data (masked in logs/traces).
maskFieldsString[]{}Field names to mask in logs for sensitive data.
allowedCallersString[]{}Agent IDs allowed to call this action. Empty = all allowed.
allowedRolesString[]{}Role names allowed to call this action. Empty = all allowed.
requiredPermissionsString[]{}Required permission names.
encryptParamsbooleanfalseEncrypt action parameters in transit.
encryptResultbooleanfalseEncrypt action result in transit.
securityTimeoutint0Max execution time (ms) before automatic abort. 0 = no timeout.
idempotentbooleanfalseWhether the action can be safely replayed. Non-idempotent actions get replay protection.

AuditLevel Enum

Choose how much detail to record in audit logs. Higher levels capture more information but generate more log volume.

LevelWhat is logged
NONENothing
BASICAction name and caller only
STANDARDAction name, caller, and parameters
DETAILEDEverything including results
FULLFull audit with timing and stack traces

Example -- Method Level

This example shows a high-security action that requires approval, detailed audit logging, restricted access, and a timeout.

@ActionSpec(type = ActionType.LOCAL, description = "Delete user account")
@Security(
    approvalRequired = true,
    approvalReason = "Permanent data deletion",
    audit = AuditLevel.DETAILED,
    sensitive = true,
    maskFields = {"password", "ssn"},
    allowedCallers = {"admin-agent"},
    requiredPermissions = {"user:delete"},
    securityTimeout = 30000
)
public void deleteUser(String userId) {
    // Requires approval, full audit trail, 30s security timeout
}

Example -- Type Level

Apply @Security to a role class to set default policies for all its actions. Individual methods can still override these defaults.

@Security(
    audit = AuditLevel.BASIC,
    allowedCallers = {"admin-agent", "supervisor-agent"},
    sensitive = true,
    encryptParams = true,
    encryptResult = true
)
public class AdminRole extends Role {
    // All actions inherit these security policies
}

Combining with Other Annotations

For maximum protection, layer multiple security annotations. This example shows a financial transaction protected by approval, full audit, input validation, and output PII masking -- defense in depth.

@ActionSpec(type = ActionType.WEB_SERVICE, description = "Transfer funds")
@Security(
    approvalRequired = true,
    approvalReason = "Financial transaction",
    audit = AuditLevel.FULL,
    sensitive = true,
    maskFields = {"accountNumber"},
    requiredPermissions = {"finance:transfer"},
    idempotent = false
)
@InputGuardrail(
    validators = {NotEmpty.class},
    detectInjection = true,
    onFailure = FailureAction.REJECT
)
@OutputGuardrail(
    maskPII = true,
    piiTypes = {PIIType.CREDIT_CARD, PIIType.SSN},
    onFailure = FailureAction.REJECT
)
public String transferFunds(String fromAccount, String toAccount, double amount) {
    // Protected by approval, input validation, output masking, and full audit
}

On this page