Capabilities
Capabilities are reusable, body-less action contracts. A @Capability interface carries one or more @ActionSpec-annotated methods that describe what the capability does; the framework dispatches the call at runtime. A role gains the capability by implements-ing the interface — without writing any method bodies.
This page covers the pattern, composition, override rules, validation, and migration from the legacy return null; style.
Available from 0.3.1. The legacy pattern (concrete methods with return null; bodies) was removed in 0.5.0.
The Problem
Before capabilities, every dispatched @ActionSpec method required a return null; body:
@ActionSpec(
type = ActionType.LLM,
description = "Summarise the text in one short paragraph",
llmSystemPrompt = "You are concise.",
llmTemperature = 0.2f
)
public String summarize(String text) {
return null; // body unused — LLMRoleExecutor handles dispatch
}The body is dead code: ActionExecutor skips method invocation entirely for LLM/MCP_TOOL/WEB_SERVICE actions when there is no ActionResult parameter. Yet the method declaration forces three problems:
- Misleading signature — static analysis marks the method as "always returns null"; downstream
@NonNullchecks inferred from this are bogus. - Silent failure on bypass — anyone who invokes the method outside the dispatch path (direct
role.summarize(x), test code, misconfigured filter) getsnull. This looks identical to a genuine LLM failure and makes debugging painful. - Duplicated specification — every role that wants the same capability copy-pastes the
@ActionSpecannotation. Drift is inevitable.
The Pattern
Move the method into a @Capability interface. The interface's default body throws Actions.dispatchedByFramework() — a loud, framework-owned marker that never executes in the normal dispatch path:
@Capability
public interface Summarizer {
@ActionSpec(
type = ActionType.LLM,
description = "Summarise the text in one short paragraph",
llmSystemPrompt = "You are concise.",
llmTemperature = 0.2f
)
default String summarize(String text) {
throw Actions.dispatchedByFramework();
}
}A role picks up the capability by implementing the interface — with no method bodies of its own:
@RoleIdentity(name = "Editor", goal = "Produce clean, readable articles")
public class EditorRole extends Role implements Summarizer {
// State, lifecycle, and other non-capability methods live here.
// No `summarize` body — inherited from Summarizer.
}When the LLM calls summarize, ActionExecutor discovers the method through the capability interface and routes dispatch through the LLM executor — exactly as it would for a concrete @ActionSpec method. The default throw body is never executed in the normal path; it only fires if someone bypasses dispatch and invokes the method directly, producing a clear DispatchedByFrameworkException instead of a silent null.
Composition
A role can implement any number of capability interfaces. Each contributes its actions to the role:
@Capability
public interface Translator {
@ActionSpec(type = ActionType.LLM, description = "Translate the text to the target language")
default String translate(String text, String targetLanguage) {
throw Actions.dispatchedByFramework();
}
}
@Capability
public interface Classifier {
@ActionSpec(type = ActionType.LLM, description = "Classify input as POSITIVE / NEGATIVE / NEUTRAL")
default String classifySentiment(String text) {
throw Actions.dispatchedByFramework();
}
}
@RoleIdentity(name = "Assistant", goal = "Handle ad-hoc requests")
public class AssistantRole extends Role implements Summarizer, Translator, Classifier {
// Three capabilities, zero bodies. All actions ready for LLM dispatch.
}ActionDiscovery walks the role's interface chain — including super-interfaces of capabilities (MultilingualSummarizer extends Summarizer) — so every capability contributes its methods regardless of whether it arrives via direct implementation or transitive extension.
Override — Role Declaration Wins
If a role declares the same signature as a capability's default method, the role's version is what ends up in the discovered action list. Use this to drop dispatch entirely for a specific role and provide a deterministic local implementation:
public class StrictEditor extends Role implements Summarizer {
// Replaces Summarizer.summarize's LLM dispatch with a deterministic local impl
@Override
@ActionSpec(type = ActionType.LOCAL, description = "Truncate-first summarise (deterministic)")
public String summarize(String text) {
return text.length() <= 60 ? text : text.substring(0, 60) + "...";
}
}Discovery sees summarize declared on the concrete class first, records its signature, and then skips the capability's default when walking the interface chain. Exactly one summarize action ends up in the role's metadata, and its type is LOCAL rather than LLM.
Validation
Two rules are enforced at action-discovery time; violations throw IllegalStateException with a message naming the offending interface and method.
Capability methods must be default
An abstract method on a @Capability interface would force every adopting role to write a body — defeating the point of the annotation. The error message points at the correct body:
@Capability interface com.example.Summarizer method summarize must be a default method.
Abstract capability methods force every adopting role to write a body, defeating the
purpose of the annotation. Use `default { throw Actions.dispatchedByFramework(); }` as the body.Capability methods must not declare an ActionResult parameter
Capabilities are pure dispatch. Post-processing (reading the LLM's raw response, running it through custom logic before returning) belongs on the concrete role class where per-role logic makes sense. The validator rejects the mixed case:
@Capability interface com.example.Summarizer method summarize must not declare an
ActionResult parameter. Capabilities are pure dispatch — move post-processing
(ActionResult-based) to a concrete method on the role class itself.If you want post-processing, keep that specific method off the capability interface and declare it directly on the role (the legacy pattern). Capability-dispatched and role-owned methods coexist on the same role without interference.
Migration
To migrate a legacy role:
- For each dispatched
@ActionSpecmethod (LLM / MCP / WEB_SERVICE) whose body isreturn null;, extract it to a@Capabilityinterface. - Give the interface method a
default { throw Actions.dispatchedByFramework(); }body. - On the role class, remove the original method entirely and add
implements YourCapability. - Methods that have a meaningful body — typically because they declare an
ActionResultparameter for post-processing — stay on the role class unchanged.
Before
@RoleIdentity(name = "Editor", goal = "Produce clean articles")
public class EditorRole extends Role {
@ActionSpec(type = ActionType.LLM, description = "Summarise the text",
llmSystemPrompt = "You are concise.", llmTemperature = 0.2f)
public String summarize(String text) {
return null;
}
@ActionSpec(type = ActionType.LLM, description = "Translate to the target language")
public String translate(String text, String targetLanguage) {
return null;
}
}After
@Capability
public interface Summarizer {
@ActionSpec(type = ActionType.LLM, description = "Summarise the text",
llmSystemPrompt = "You are concise.", llmTemperature = 0.2f)
default String summarize(String text) {
throw Actions.dispatchedByFramework();
}
}
@Capability
public interface Translator {
@ActionSpec(type = ActionType.LLM, description = "Translate to the target language")
default String translate(String text, String targetLanguage) {
throw Actions.dispatchedByFramework();
}
}
@RoleIdentity(name = "Editor", goal = "Produce clean articles")
public class EditorRole extends Role implements Summarizer, Translator {
// No capability bodies.
}The first time Summarizer is used by another role, the duplication problem is already solved — update the prompt once, every adopter gets the change.
When Not to Use Capabilities
ActionType.LOCALmethods — these have real bodies that the framework invokes via reflection. They are not "framework-dispatched" and do not have thereturn null;problem. Leave them on the concrete role class.- Methods that declare an
ActionResultparameter — they are validated-out of capability interfaces by design (see above). Keep them on the role. - One-off methods used by a single role with no prospect of reuse — extracting a capability interface for one consumer adds indirection without benefit. Capabilities shine when two or more roles share the same
@ActionSpec.
Related
- Action System — routing,
ActionTypeenum, executor types. - Roles — role identity, responsibilities, lifecycle.
Implementation References
com.tnsai.capabilities.Capability— the marker annotation (@Target(TYPE),@Retention(RUNTIME)).com.tnsai.actions.Actions.dispatchedByFramework()— helper returningDispatchedByFrameworkException(subtype ofUnsupportedOperationException).com.tnsai.actions.ActionDiscovery— two-pass discovery: role class first, then the capability interface chain.
Action System
The action system is the execution backbone of TnsAI. When an LLM decides to call a function, or an agent needs to perform work, the request flows through ActionExecutor, which routes it to the appropriate executor based on the action's ActionType.
Event System
The event system provides full observability into the agent lifecycle. Events use a sealed interface hierarchy with 20+ event types, enabling type-safe pattern matching.