Skip to content
tnsaiDocs
GitHub

Create an agent

Build an agent with AgentBuilder, or as an annotated Agent subclass, then chat with it.

An Agent owns an LLM client, one or more roles, a memory store and an event system. On each chat turn it sends the message and the roles' actions to the model, runs the tool calls the model makes, and returns the reply.

Build an agent with AgentBuilder

AgentBuilder assembles an agent in one fluent chain. build() needs at least one role, a principal and an authorityScope. A liabilitySink is optional. See Principal and authority.

package com.example.tnsai.docs;

import com.tnsai.accountability.AuthorityScope;
import com.tnsai.accountability.RecordingLiabilitySink;
import com.tnsai.agents.Agent;
import com.tnsai.agents.AgentBuilder;
import com.tnsai.identity.AgentDescriptor;
import com.tnsai.identity.LocalIdentityProvider;
import com.tnsai.llm.LLMClientFactory;
import com.tnsai.roles.RoleBuilder;

import java.time.Duration;
import java.util.List;

public final class FundamentalsBuilderExample {
    private FundamentalsBuilderExample() {}

    public static Agent build() {
        var principal = new LocalIdentityProvider().issue(
            AgentDescriptor.builder()
                .agentClass("com.example.AssistantAgent")
                .systemPrompt("You are a helpful assistant.")
                .toolNames(List.of())
                .model("openai:gpt-4o")
                .build());

        return AgentBuilder.create()
            .llm(LLMClientFactory.create("openai", "gpt-4o", 0.7f))
            .role(RoleBuilder.create()
                .name("Assistant")
                .goal("Help users with their questions")
                .build())
            .principal(principal)
            .liabilitySink(new RecordingLiabilitySink())
            .authorityScope(AuthorityScope.unrestricted(Duration.ofHours(1)))
            .build();
    }
}
String reply = FundamentalsBuilderExample.build().chat("Summarize the release notes in three bullets");

build() first runs a validation pass over the configuration. Every error-level finding is collected into one AgentValidationException, and warnings are logged. Without a principal or an authority scope, build() throws IllegalStateException.

A fuller chain sets the id, several roles, toolkits, memory and a context limit:

package com.example.tnsai.docs;

import com.tnsai.accountability.AuthorityScope;
import com.tnsai.accountability.LiabilitySink;
import com.tnsai.agents.Agent;
import com.tnsai.agents.AgentBuilder;
import com.tnsai.enums.BuiltInTool;
import com.tnsai.identity.AgentPrincipal;
import com.tnsai.llm.providers.OpenAIClient;
import com.tnsai.memory.InMemoryStore;
import com.tnsai.roles.Role;

import java.util.List;

public final class FundamentalsConfigExample {
    private FundamentalsConfigExample() {}

    public static Agent build(
        Role myRole,
        Role role1,
        Role role2,
        Object myDomainTools,
        AgentPrincipal principal,
        LiabilitySink liabilitySink,
        AuthorityScope authorityScope
    ) {
        return AgentBuilder.create()
            .id("agent-001")
            .llm(new OpenAIClient("gpt-4o"))
            .role(myRole)
            .roles(List.of(role1, role2))
            .builtInTools(BuiltInTool.WEB_SEARCH_TOOLS, BuiltInTool.UTILITY_TOOLS)
            .toolPojos(myDomainTools)
            .memoryStore(new InMemoryStore())
            .maxContextTokens(8192)
            .principal(principal)
            .liabilitySink(liabilitySink)
            .authorityScope(authorityScope)
            .build();
    }
}

Write an annotated agent class

Extend Agent when you want a reusable class with its configuration on it. getRoles() is abstract, and getLLM() supplies the agent's client. AssistantRole comes from the quickstart.

package com.example.tnsai.docs;

import com.tnsai.accountability.AuthorityScope;
import com.tnsai.actions.ActionExecutor;
import com.tnsai.agents.Agent;
import com.tnsai.annotations.AgentSpec;
import com.tnsai.identity.AgentDescriptor;
import com.tnsai.identity.AgentPrincipal;
import com.tnsai.identity.LocalIdentityProvider;
import com.tnsai.llm.LLMClient;
import com.tnsai.llm.providers.AnthropicClient;
import com.tnsai.roles.Role;

import java.time.Duration;
import java.util.List;

@AgentSpec(description = "A helpful research assistant")
public final class AnnotatedAgentExample extends Agent {

    public AnnotatedAgentExample(AgentPrincipal principal, AuthorityScope scope) {
        super("annotated-agent", null, null, wired(principal, scope));
    }

    private static ActionExecutor wired(AgentPrincipal principal, AuthorityScope scope) {
        ActionExecutor executor = new ActionExecutor();
        executor.setPrincipal(principal);
        executor.setDefaultScope(scope);
        return executor;
    }

    @Override
    protected LLMClient getLLM() {
        return new AnthropicClient("claude-sonnet-4-20250514");
    }

    @Override
    protected List<Role> getRoles() {
        return List.of(Role.create(QuickstartBuilderExample.AssistantRole.class));
    }

    public static void main(String[] args) {
        var principal = new LocalIdentityProvider().issue(
                AgentDescriptor.builder()
                        .agentClass(AnnotatedAgentExample.class.getName())
                        .systemPrompt("You are a helpful research assistant.")
                        .toolNames(List.of("greet"))
                        .model("anthropic:claude-sonnet-4-20250514")
                        .build());
        var agent = new AnnotatedAgentExample(
                principal, AuthorityScope.unrestricted(Duration.ofHours(1)));
        agent.start();
        String answer = agent.chat("What is quantum computing?");
        System.out.println(answer);
    }
}

A direct subclass does not get accountability wiring from a builder. Pass it an ActionExecutor on which setPrincipal and setDefaultScope have been called, through the four-argument constructor as above. Without them, the agent's first action is refused before its body runs. See Principal and authority.

  • Name. The agent name comes from the class: a trailing Agent is dropped and the words are split, so ResearchAgent becomes "Research". @AgentSpec has no name element.
  • Roles. Return the roles from getRoles(), or return an empty list to use the role classes in @AgentSpec.roles. Each annotation-declared role needs a public no-arg constructor. A role that takes constructor arguments goes through getRoles() or AgentBuilder.role(...).
  • LLM. @AgentSpec(llm = …) compiles but is not read for the agent's client. Use getLLM() or AgentBuilder.llm(...). @LLMSpec is valid only nested, and @RoleSpec(llm = …) gives a role its own client.

Inherited @AgentSpec

@AgentSpec is @Inherited. An agent class without its own @AgentSpec takes its nearest superclass's whole. Fields are not merged: a subclass that declares its own @AgentSpec replaces the superclass's entirely. An @AgentSpec on an interface is not inherited.

When concrete agents extend an annotated abstract base and declare none, each one takes the base's settings:

  • Knowledge, @Config file, roles and groups. Each agent binds and indexes the inherited knowledge itself, so inherited DIRECT knowledge needs a retrieval runtime such as tnsai-rag at start. It joins the base's groups on start when autoJoin is set, and plays the base's roles when given none in code. An inherited @Config file's roles sections bind the subclass's roles, so a subclass that plays fewer roles than they name fails at start.
  • Memory and communication. An inherited memory with FILE persistence writes each agent's history to disk, and REDIS or DATABASE opens a connection per agent. A getMemoryStore() or getCommunication() override below the annotated class, or a @CommunicationStyle on the agent's own class, replaces the inherited value.
  • DID. A fixed agentId in an inherited did fails at start, so declare it on the class's own @AgentSpec. An inherited did without an agentId gives each agent a DID of its own id.
  • A subclass with only @RoleSpec has both annotations. Its knowledge is its role's and the inherited agent's together, so it fails at start when both name a @Config file or declare knowledge of the same name.

The other members (autoStart, idleTimeoutMs, toolCallFilter, maxContextTokens, description and version) are inherited too. A run file section keyed by the superclass configures only the superclass. See Configuration files and the run file.

To opt out, declare an @AgentSpec on the subclass.

Chat with the agent

// One turn with conversation history and tools
String reply = agent.chat("Explain quantum computing");

// Without history
String translated = agent.chat("Translate this to French", false);

// Tokens as they arrive
Stream<String> tokens = agent.streamChat("Write a poem about Java");
tokens.forEach(System.out::print);

// Watch tool calls inside the loop
String answer = agent.chatWithEvents("Research AI safety", event -> {
    switch (event) {
        case ToolCallStartEvent e -> System.out.println("Calling: " + e.toolName());
        case ToolCallEndEvent e -> System.out.println("Result: " + e.result());
        case ErrorEvent e -> System.err.println("Error: " + e.message());
        default -> {}
    }
});

chat(message, useHistory, useTools) also turns tools off for a turn. See Streaming and Events.

Manage conversation history

List<Map<String, Object>> history = agent.getConversationHistory();
agent.clearConversationHistory();

Agent.getMemoryStore() is protected. To add messages or prune, keep the MemoryStore you passed to AgentBuilder.memoryStore(...):

memoryStore.addMessage("user", "Remember this context");
memoryStore.prune(4096); // drops the oldest messages first

See Memory.

Start and stop

start() does nothing, because protocols start when their annotations are detected. stop() releases the agent's resources and is safe to call twice. getHealthState() returns the agent's AgentHealthState.

agent.start();
AgentHealthState health = agent.getHealthState();
agent.stop();

Builder settings

SettingBuilder methodDefaultDescription
ID.id(String)GeneratedUnique agent identifier
LLM.llm(LLMClient)NoneModel client. Omit it for an agent without an LLM
Roles.role(Role) / .roles(List)RequiredThe agent's roles
Principal.principal(AgentPrincipal)RequiredWho the agent acts as
Authority scope.authorityScope(AuthorityScope)RequiredWhat the agent may do
Liability sink.liabilitySink(LiabilitySink)NoneWhere liability records go. None are built without one
Description.description(String)NoneAgent description
Built-in toolkits.builtInTools(BuiltInTool...)NoneToolkits shipped in tnsai-tools
Custom toolkits.toolPojos(Object...)NoneYour objects with @Tool methods
Runtime tools.dynamicTool(DynamicToolMethod)NoneTools known only at runtime, such as MCP proxies
Tool call filter.toolCallFilter(ToolCallFilter)NoneConsulted before each tool call. Validation warns when a confirmation-required tool has no filter
Memory.memoryStore(MemoryStore)InMemoryStoreConversation memory
Context limit.maxContextTokens(int)Provider defaultMaximum context window
Idle timeout.idleTimeoutMs(int)0Suspends an idle agent. 0 never suspends
Knowledge.knowledge(KnowledgeEntry...)NoneReplaces annotation entries of the same name; other entries join chat. .actionKnowledge(action, names...) sets one action's entries
Reasoning.reasoningStrategy(String)NoneA named reasoning strategy from tnsai-intelligence. Ignored when the module is absent
Tenant.tenantId(String)NoneBinds each public chat turn to TenantContext
Validation.relaxValidation(String) / (Set<String>)NoneTurns off a validation check by its code