TnsAI
Tutorials

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_KEY and ALERTS_API_TOKEN for 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

AuthTypeWhat it sendsRequired env var(s)
NO_AUTHNothing
BEARERAuthorization: Bearer <token>authTokenEnv
BASICAuthorization: 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 fieldDefaultWhen to override
timeout30000 msSlow upstreams, real-time dashboards
retryCount0Idempotent reads on flaky networks
retryBackoffMs1000 msAdjust backoff cadence
followRedirectstrueSet false for strict single-hop POSTs
contentTypeapplication/jsonUse application/x-www-form-urlencoded etc.
acceptapplication/jsonWhen 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
CallerLLM picks the action via tool-call; framework calls the endpointLLM picks the action, then picks one of the agent's @Tool methods to call
Wire formatHTTP endpoint declared in annotationMethod-level @Tool schema on the registered POJO
AuthPer-action env-var injectionPer-toolkit env var (read by the POJO)
Best forWrapping a known REST endpoint with typed parametersOpen-ended action where the LLM should choose among several tools
Setup costJust the annotationRegister 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
  • @WebService annotation — tnsai-core/src/main/java/com/tnsai/annotations/WebService.java
  • WebServiceExecutortnsai-core/src/main/java/com/tnsai/actions/executors/WebServiceExecutor.java
  • WebServiceConfig record — tnsai-core/src/main/java/com/tnsai/metadata/WebServiceConfig.java (the grouped accessor used by executors)
  • WeatherRoleAnnotationRoundTripTesttnsai-integration/src/test/java/com/tnsai/integration/scop/examples/ (annotation-contract sentinel)

On this page