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
Agentis dropped and the words are split, soResearchAgentbecomes"Research".@AgentSpechas nonameelement. - 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 throughgetRoles()orAgentBuilder.role(...). - LLM.
@AgentSpec(llm = …)compiles but is not read for the agent's client. UsegetLLM()orAgentBuilder.llm(...).@LLMSpecis 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,
@Configfile, roles and groups. Each agent binds and indexes the inherited knowledge itself, so inheritedDIRECTknowledge needs a retrieval runtime such astnsai-ragat start. It joins the base's groups on start whenautoJoinis set, and plays the base'sroleswhen given none in code. An inherited@Configfile'srolessections bind the subclass's roles, so a subclass that plays fewer roles than they name fails at start. - Memory and communication. An inherited
memorywithFILEpersistence writes each agent's history to disk, andREDISorDATABASEopens a connection per agent. AgetMemoryStore()orgetCommunication()override below the annotated class, or a@CommunicationStyleon the agent's own class, replaces the inherited value. - DID. A fixed
agentIdin an inheriteddidfails at start, so declare it on the class's own@AgentSpec. An inheriteddidwithout anagentIdgives each agent a DID of its own id. - A subclass with only
@RoleSpechas both annotations. Its knowledge is its role's and the inherited agent's together, so it fails at start when both name a@Configfile 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 firstSee 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
| Setting | Builder method | Default | Description |
|---|---|---|---|
| ID | .id(String) | Generated | Unique agent identifier |
| LLM | .llm(LLMClient) | None | Model client. Omit it for an agent without an LLM |
| Roles | .role(Role) / .roles(List) | Required | The agent's roles |
| Principal | .principal(AgentPrincipal) | Required | Who the agent acts as |
| Authority scope | .authorityScope(AuthorityScope) | Required | What the agent may do |
| Liability sink | .liabilitySink(LiabilitySink) | None | Where liability records go. None are built without one |
| Description | .description(String) | None | Agent description |
| Built-in toolkits | .builtInTools(BuiltInTool...) | None | Toolkits shipped in tnsai-tools |
| Custom toolkits | .toolPojos(Object...) | None | Your objects with @Tool methods |
| Runtime tools | .dynamicTool(DynamicToolMethod) | None | Tools known only at runtime, such as MCP proxies |
| Tool call filter | .toolCallFilter(ToolCallFilter) | None | Consulted before each tool call. Validation warns when a confirmation-required tool has no filter |
| Memory | .memoryStore(MemoryStore) | InMemoryStore | Conversation memory |
| Context limit | .maxContextTokens(int) | Provider default | Maximum context window |
| Idle timeout | .idleTimeoutMs(int) | 0 | Suspends an idle agent. 0 never suspends |
| Knowledge | .knowledge(KnowledgeEntry...) | None | Replaces annotation entries of the same name; other entries join chat. .actionKnowledge(action, names...) sets one action's entries |
| Reasoning | .reasoningStrategy(String) | None | A named reasoning strategy from tnsai-intelligence. Ignored when the module is absent |
| Tenant | .tenantId(String) | None | Binds each public chat turn to TenantContext |
| Validation | .relaxValidation(String) / (Set<String>) | None | Turns off a validation check by its code |
Related
- Principal and authority: the identity and scope
build()requires. - Roles: group actions into roles.
- Actions: what an agent can do.
- Events: the agent's event stream.
- SPI reference: extension points loaded from the classpath.