Tutorial: Reusable Capabilities
Build an editorial agent that can summarise, translate, and classify sentiment — without writing a single body for those action methods. This tutorial walks through the @Capability pattern, showing composition, override, and the common mistakes the framework prevents.
Goal
At the end of the tutorial you will have:
- Three reusable capability interfaces (
Summarizer,Translator,SentimentClassifier) that can be shared across any number of roles. - An
EditorRolethat composes all three — zero method bodies for dispatched actions. - A
StrictEditorvariant that overrides one capability with a deterministic local implementation — demonstrating the role-wins dedupe rule. - A minimal test exercising the agent end-to-end.
Total work: about 40 lines of interface code, 15 lines of role code, 15 lines of test.
Prerequisites
- Installation
- An LLM provider configured (any one of Anthropic, OpenAI, Ollama, etc.)
- Familiarity with Roles and the Action System
- Recommended: skim Capabilities first for the conceptual overview
Step 1 — Define the Capabilities
Each capability is an interface annotated @Capability. The methods carry their usual @ActionSpec metadata; the default bodies throw Actions.dispatchedByFramework() so bypass calls fail loudly instead of silently returning null.
Per-action LLM overrides (system prompt, temperature) live directly on @ActionSpec — no nested annotation needed.
Summarizer
package com.example.tutorial.capabilities;
import com.tnsai.actions.Actions;
import com.tnsai.annotations.ActionSpec;
import com.tnsai.capabilities.Capability;
import com.tnsai.enums.ActionType;
@Capability
public interface Summarizer {
@ActionSpec(
type = ActionType.LLM,
description = "Summarise the given text in one short paragraph of at most 60 words.",
llmSystemPrompt = "You are a concise summariser. Output plain text only, no preamble.",
llmTemperature = 0.2f
)
default String summarize(String text) {
throw Actions.dispatchedByFramework();
}
}Translator
package com.example.tutorial.capabilities;
import com.tnsai.actions.Actions;
import com.tnsai.annotations.ActionSpec;
import com.tnsai.capabilities.Capability;
import com.tnsai.enums.ActionType;
@Capability
public interface Translator {
@ActionSpec(
type = ActionType.LLM,
description = "Translate the given text to the target language, preserving tone.",
llmSystemPrompt = "You are a precise translator. Output only the translation.",
llmTemperature = 0.1f
)
default String translate(String text, String targetLanguage) {
throw Actions.dispatchedByFramework();
}
}SentimentClassifier
package com.example.tutorial.capabilities;
import com.tnsai.actions.Actions;
import com.tnsai.annotations.ActionSpec;
import com.tnsai.capabilities.Capability;
import com.tnsai.enums.ActionType;
@Capability
public interface SentimentClassifier {
@ActionSpec(
type = ActionType.LLM,
description = "Classify the sentiment of the input as POSITIVE, NEGATIVE, or NEUTRAL.",
llmSystemPrompt = "Output exactly one of: POSITIVE, NEGATIVE, NEUTRAL. Nothing else.",
llmTemperature = 0.0f
)
default String classifySentiment(String text) {
throw Actions.dispatchedByFramework();
}
}Each capability is now a first-class reusable contract. Any role in your codebase can gain these abilities by implements-ing the interface.
Step 2 — Compose Them Onto a Role
The role class is where agent state lives (memory, history, lifecycle). Capabilities are added by implementing their interfaces — no method bodies needed:
package com.example.tutorial.roles;
import com.example.tutorial.capabilities.Summarizer;
import com.example.tutorial.capabilities.Translator;
import com.example.tutorial.capabilities.SentimentClassifier;
import com.tnsai.annotations.RoleIdentity;
import com.tnsai.models.role.Responsibility;
import com.tnsai.roles.Role;
import java.util.List;
@RoleIdentity(
name = "Editor",
goal = "Produce clean, readable articles in any target language"
)
public class EditorRole extends Role implements Summarizer, Translator, SentimentClassifier {
@Override
public List<Responsibility> getResponsibilities() {
return List.of(
new Responsibility("Condense source material", "High"),
new Responsibility("Render outputs in the requested language", "High"),
new Responsibility("Gauge reader sentiment on drafts", "Medium")
);
}
// Agent state + lifecycle methods go here. No capability bodies — inherited.
}ActionDiscovery walks the interface chain at role initialisation, picks up the three @ActionSpec methods from the capability interfaces, and registers them as the role's actions. The LLM sees all three as tools it can call; dispatch flows through ActionExecutor → LLMRoleExecutor exactly as it would for a concrete @ActionSpec method.
Step 3 — Wire Into an Agent
import com.example.tutorial.roles.EditorRole;
import com.tnsai.agents.Agent;
import com.tnsai.agents.AgentBuilder;
import com.tnsai.llm.LLMClientFactory;
import com.tnsai.roles.Role;
public class TutorialMain {
public static void main(String[] args) {
Agent editor = AgentBuilder.create()
.llm(LLMClientFactory.create("openai", "gpt-4o", 0.3f))
.role(Role.create(EditorRole.class))
.build();
// The LLM now has three tools available: summarize, translate, classifySentiment.
String reply = editor.chat(
"Please summarise this in one line, then translate the summary to Turkish: "
+ "The framework pattern eliminates boilerplate by moving dispatched method "
+ "contracts into reusable interfaces. Roles compose them without bodies."
);
System.out.println(reply);
}
}Run it; the LLM picks summarize, then picks translate, and produces the combined output. Your codebase contains zero lines of return null; — the contracts are expressed once, on the capability interfaces.
Step 4 — Override When You Need Deterministic Behaviour
A role that implements a capability can also declare its own version of the method. When that happens, the role's declaration wins; the capability's default is skipped. Use this when a specific role needs deterministic, non-LLM behaviour for one of the contracted actions:
package com.example.tutorial.roles;
import com.example.tutorial.capabilities.Summarizer;
import com.tnsai.annotations.ActionSpec;
import com.tnsai.annotations.RoleIdentity;
import com.tnsai.enums.ActionType;
import com.tnsai.models.role.Responsibility;
import com.tnsai.roles.Role;
import java.util.List;
@RoleIdentity(
name = "StrictEditor",
goal = "Truncate-first summarise (deterministic, no LLM)"
)
public class StrictEditor extends Role implements Summarizer {
@Override
public List<Responsibility> getResponsibilities() {
return List.of(new Responsibility("Produce identical output for identical input", "High"));
}
// Overrides Summarizer.summarize — type flips from LLM to LOCAL.
@Override
@ActionSpec(
type = ActionType.LOCAL,
description = "Truncate to the first 60 characters (no LLM, reproducible)."
)
public String summarize(String text) {
return text.length() <= 60 ? text : text.substring(0, 60) + "...";
}
}When ActionDiscovery scans StrictEditor, it records the class-declared summarize signature on the first pass, then skips Summarizer.summarize on the capability-interface pass. Exactly one summarize action ends up in the role's metadata, and its ActionType is LOCAL rather than LLM. The LLM still sees an action called summarize; invoking it just runs the deterministic body instead of hitting the model.
Step 5 — Test
import com.example.tutorial.roles.EditorRole;
import com.tnsai.actions.ActionDiscovery;
import com.tnsai.metadata.ActionMetadata;
import com.tnsai.metadata.DiscoveredRoleActions;
import org.junit.jupiter.api.Test;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
class EditorRoleCapabilityTest {
@Test
void editorGainsAllThreeCapabilityActions() {
DiscoveredRoleActions discovered = ActionDiscovery.discoverActions(EditorRole.class);
Set<String> actionNames = discovered.getActions().stream()
.map(ActionMetadata::getName)
.collect(Collectors.toSet());
assertEquals(
Set.of("summarize", "translate", "classifySentiment"),
actionNames,
"Role must gain actions from every @Capability interface it implements"
);
}
}ActionDiscovery.discoverActions is synchronous and pure — it works at unit-test time without an LLM client. A passing test here proves the composition works before you ever hit the network.
Common Mistakes the Framework Catches
Two misuses of @Capability fail fast at discovery time with an IllegalStateException. Knowing them up front saves a debugging round:
- Abstract method (no
defaultbody) — a capability method without a default forces every adopter to write a body, defeating the whole point. The error message names the offending interface and method, and points todefault { throw Actions.dispatchedByFramework(); }as the fix. ActionResultparameter on a capability method — capabilities are pure dispatch. If a method needs post-processing on the LLM's raw response, keep that method on the concrete role class (the legacy pattern) rather than on the capability interface.
The validator message is explicit in both cases; you don't have to guess what went wrong.
What Not to Turn Into a Capability
ActionType.LOCALmethods — they have real bodies the framework invokes via reflection. They don't suffer from thereturn nullproblem.- Methods with
ActionResultparameters — rejected by the validator by design. - One-off methods used by a single role — extracting a capability interface for one consumer adds indirection without reuse benefit. Inline is fine.
Related
- Capabilities — concept reference, validation rules, migration table
- Action System — how
ActionExecutorroutes dispatched calls - Roles — declaring roles,
@RoleIdentity, responsibilities
Implementation References
com.tnsai.capabilities.Capability— marker annotationcom.tnsai.actions.Actions.dispatchedByFramework()— exception-returning helpercom.tnsai.actions.ActionDiscovery— two-pass discovery (class methods, then@Capabilityinterface methods)