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.",
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 parameter 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(
String q,
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(
String cityId,
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 parameter 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(
String city,
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 the parameter list 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(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)
Tutorial: Declarative resilience with @Traced, @Metered, and @Fallback
Wrap every action dispatch with structured tracing, latency + counter metrics, and automatic recovery on failure — through annotations alone. The framework's ActionExecutor applies the decorators in canonical order; your action body stays focused on the business logic.
Tutorial: Customer Support Bot
A full code walkthrough for this tutorial is still being written. In the meantime this page describes the shape of the system and links to the framework pieces you'd compose to build it. If you start the implementation and hit something missing, open a discussion — concrete questions accelerate the write-up.