Tutorial: REST API actions with @WebService
Bind any REST endpoint to an LLM-callable action through annotations alone — no OkHttpClient plumbing, no manual JSON parsing, no per-call retry loops in your Role. The framework's WebServiceExecutor handles the wire-format work; your Role just declares what to call.
Prerequisites
- Installation
OPENAI_API_KEY(LLM)- A bearer token in
OPENWEATHER_API_KEYandALERTS_API_TOKENfor the example endpoints (or substitute your own)
The role
Full source lives at tnsai-integration/src/main/java/com/tnsai/integration/scop/examples/WeatherRole.java and is exercised by WeatherRoleAnnotationRoundTripTest. The shape:
@RoleSpec(
name = "WeatherAgent",
description = "Looks up current weather and forecasts via OpenWeatherMap.",
responsibilities = {
@Responsibility(
name = "WeatherLookup",
actions = {"getCurrentWeather", "getForecastByCityId"}),
@Responsibility(
name = "AlertSubscription",
actions = {"subscribeToAlerts"})
},
llm = @LLMSpec(
provider = LLMSpec.Provider.OPENAI,
model = "gpt-4o-mini",
temperature = 0.2f)
)
public class WeatherRole {
// ... actions below
}Pattern 1 — GET with query params + bearer auth
The canonical "fetch from a public API" shape. Each @Param value is URL-encoded and appended as a query parameter; the bearer token is read from the env var at request time.
@ActionSpec(
type = ActionType.WEB_SERVICE,
description = "Current weather conditions for a city, by name.",
webService = @WebService(
endpoint = "https://api.openweathermap.org/data/2.5/weather",
method = HttpMethod.GET,
paramType = ParamType.QUERY,
auth = AuthType.BEARER,
authTokenEnv = "OPENWEATHER_API_KEY",
timeout = 5000,
retryCount = 2,
retryBackoffMs = 500
)
)
public String getCurrentWeather(
@Param(name = "q") String q,
@Param(name = "units") String units
) {
return null; // body unused — WebServiceExecutor calls the API
}The framework's WebServiceExecutor resolves this annotation at dispatch time, builds the URL (?q=Istanbul&units=metric), injects Authorization: Bearer $OPENWEATHER_API_KEY, and parses the JSON response back into the action's return type.
Pattern 2 — GET with path templating
Single-brace {paramName} syntax substitutes path variables before the request leaves the JVM. Remaining params still go to the query string when paramType = QUERY.
@ActionSpec(
type = ActionType.WEB_SERVICE,
description = "N-day forecast for a city by OpenWeatherMap city id.",
webService = @WebService(
endpoint = "https://api.openweathermap.org/data/2.5/forecast/{cityId}",
method = HttpMethod.GET,
paramType = ParamType.QUERY,
auth = AuthType.BEARER,
authTokenEnv = "OPENWEATHER_API_KEY",
timeout = 7500
)
)
public String getForecastByCityId(
@Param(name = "cityId") String cityId,
@Param(name = "cnt") int cnt
) {
return null;
}Note: the framework uses single-brace {cityId} syntax, not ${cityId}. The Javadoc on @WebService.endpoint() is the authority.
Pattern 3 — POST with body, custom headers, separate auth token
paramType = BODY serialises @Param values as a JSON request body. Custom @Header entries pass through verbatim (useful for upstream-required identifiers). Each action gets its own authTokenEnv, so different services can use different bearer tokens within the same role.
@ActionSpec(
type = ActionType.WEB_SERVICE,
description = "Subscribe a user to weather-threshold alerts.",
webService = @WebService(
endpoint = "https://api.example.com/alerts/subscribe",
method = HttpMethod.POST,
paramType = ParamType.BODY,
contentType = "application/json",
accept = "application/json",
auth = AuthType.BEARER,
authTokenEnv = "ALERTS_API_TOKEN",
headers = {
@Header(key = "X-Source", value = "tnsai-weather-agent"),
@Header(key = "Accept-Language", value = "en-US")
},
timeout = 10000
)
)
public String subscribeToAlerts(
@Param(name = "city") String city,
@Param(name = "thresholdC") int thresholdC
) {
return null;
}Method bodies are deliberately stubs
return null; (or any trivial return) is a documented framework convention. WebServiceExecutor never invokes the method body — the Java signature exists purely so @Param annotations and the LLM-tool schema can be generated from it. The actual return value comes from JSON deserialization of the HTTP response.
How SCOP routes these
Every @ActionSpec(type = WEB_SERVICE) on a registered Role is dispatched by WebServiceExecutor (in tnsai-core/actions/executors/). The framework's ActionExecutor routing table wires WEB_SERVICE → WebServiceExecutor at construction (ActionExecutor.java:177); no consumer code needs to register it. SCOPBridge.executeAction(role, name, params) is the entry point for SCOP-driven invocations.
Wiring the role into an Agent
Agent agent = AgentBuilder.create()
.role(new WeatherRole())
.llm(new OpenAIClient("gpt-4o-mini"))
.build();
Object current = agent.executeAction(
"getCurrentWeather",
Map.of("q", "Istanbul", "units", "metric"));No tool registration call needed — the framework reads @WebService reflectively from the role's methods, populates ActionMetadata.webServiceConfig(), and the executor pulls every field at dispatch time.
Authentication today
AuthType | What it sends | Required env var(s) |
|---|---|---|
NO_AUTH | Nothing | — |
BEARER | Authorization: Bearer <token> | authTokenEnv |
BASIC | Authorization: Basic <base64(user:pass)> | authUsernameEnv + authPasswordEnv |
API_KEY | <apiKeyHeader>: <key> (default header X-API-Key) | authTokenEnv (+ optional apiKeyHeader to override the default header name) |
API_KEY example — upstream expects X-Goog-Api-Key: … instead of a standard Authorization header:
@ActionSpec(
type = ActionType.WEB_SERVICE,
description = "Search Google Books",
webService = @WebService(
endpoint = "https://www.googleapis.com/books/v1/volumes",
method = HttpMethod.GET,
paramType = ParamType.QUERY,
auth = AuthType.API_KEY,
authTokenEnv = "GOOGLE_BOOKS_API_KEY",
apiKeyHeader = "X-Goog-Api-Key"
)
)
public BookSearchResult searchBooks(@Param(name = "q") String query) {
return null;
}Retry, timeout, and headers
| Annotation field | Default | When to override |
|---|---|---|
timeout | 30000 ms | Slow upstreams, real-time dashboards |
retryCount | 0 | Idempotent reads on flaky networks |
retryBackoffMs | 1000 ms | Adjust backoff cadence |
followRedirects | true | Set false for strict single-hop POSTs |
contentType | application/json | Use application/x-www-form-urlencoded etc. |
accept | application/json | When the upstream returns XML / CSV |
headers | {} | Per-request static headers (auth tokens go in authTokenEnv, not here) |
When to use @WebService vs an LLM action with tools
Both surface as LLM-callable actions, but they solve different problems:
@ActionSpec(type = WEB_SERVICE) | @ActionSpec(type = LLM) + agent toolkits | |
|---|---|---|
| Caller | LLM picks the action via tool-call; framework calls the endpoint | LLM picks the action, then picks one of the agent's @Tool methods to call |
| Wire format | HTTP endpoint declared in annotation | Method-level @Tool schema on the registered POJO |
| Auth | Per-action env-var injection | Per-toolkit env var (read by the POJO) |
| Best for | Wrapping a known REST endpoint with typed parameters | Open-ended action where the LLM should choose among several tools |
| Setup cost | Just the annotation | Register a POJO toolkit via AgentBuilder.builtInTools(...) or .toolPojos(...) |
If you have one specific REST endpoint to call with typed parameters, use @WebService. If the LLM should pick which tool to call from a set you've registered with the agent, use an LLM action and let the dispatcher handle tool routing.
Reference
- SCOP Bridge — the integration layer reference
@WebServiceannotation —tnsai-core/src/main/java/com/tnsai/annotations/WebService.javaWebServiceExecutor—tnsai-core/src/main/java/com/tnsai/actions/executors/WebServiceExecutor.javaWebServiceConfigrecord —tnsai-core/src/main/java/com/tnsai/metadata/WebServiceConfig.java(the grouped accessor used by executors)WeatherRoleAnnotationRoundTripTest—tnsai-integration/src/test/java/com/tnsai/integration/scop/examples/(annotation-contract sentinel)