Lesson 02 · Effect AgentCore

The agent as an Effect HttpApi

Lesson 01 said the AgentCore runtime contract is two HTTP routes. Now we make it real: a typed HttpApi from effect/unstable/httpapi, served on 0.0.0.0:8080 with @effect/platform-node. Along the way we meet the pattern the whole course leans on — Init vs Runtime, the "Effect returning an Effect" split — and end by curling a server that actually answers.

01From contract to endpoints

An HttpApi is a description of routes — their paths, request payloads, and response shapes — separate from the code that implements them. We declare two endpoints with schemas, so the request body and the responses are validated and typed for us.

import { Effect, Layer, Schema } from "effect";
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiBuilder } from "effect/unstable/httpapi";

const PingResponse = Schema.Struct({ status: Schema.Literal("Healthy") });
const InvocationRequest = Schema.Struct({ prompt: Schema.String });
const InvocationResponse = Schema.Struct({ response: Schema.String });

const ping = HttpApiEndpoint.get("ping", "/ping", { success: PingResponse });
const invoke = HttpApiEndpoint.post("invoke", "/invocations", {
  payload: InvocationRequest,
  success: InvocationResponse,
});

HttpApiEndpoint.get takes a name (used later to find the handler), a path, and an options object. post additionally takes a payload schema — the JSON body, decoded and validated before your handler ever runs. The success schema is what we promise to return.

src/effect-agentcore/src/agent.ts · API shapes from effect-http-api/ecom/products/endpoints.ts

02Assembling the API

Endpoints go into a group; groups go into an HttpApi. Our contract is small, so one group with two endpoints is the whole surface:

export const AgentApi = HttpApi.make("agent").add(
  HttpApiGroup.make("agent").add(ping).add(invoke),
);

Nothing runs yet — AgentApi is a value describing the contract. The type system now knows that /invocations takes { prompt: string } and returns { response: string }, and it will refuse to compile a handler that returns anything else.

03The phase split: Init vs Runtime

This is the most important idea in the lesson, and it recurs in every deploy lesson. A server has two timescales:

Effect expresses this as an Effect that returns an Effect. The build function we pass to HttpApiBuilder.group is the init Effect — it runs once. Inside it we yield* LanguageModel to grab the model. The handle callbacks return the runtime Effect — they run per request and close over what init acquired.

export const AgentHandlers = HttpApiBuilder.group(AgentApi, "agent", (handlers) =>
  Effect.gen(function* () {
    const model = yield* LanguageModel;          // ← INIT: acquired once

    return handlers
      .handle("ping", () => Effect.succeed({ status: "Healthy" as const }))
      .handle("invoke", (ctx) =>                 // ← RUNTIME: runs per request
        respond(ctx.payload.prompt).pipe(
          Effect.provideService(LanguageModel, model),
          Effect.map((response) => ({ response })),
        ),
      );
  }),
);
Gotcha we hit building this

If you let the handler require LanguageModel lazily (return respond(...) directly, with respond needing the service), the requirement is tracked as a per-request Request<"Requires", LanguageModel> that a normal Layer.provide at the group boundary will not clear — and the server won't compile. Acquiring the model in the init function and providing it per request (Effect.provideService) is what makes the requirement resolvable. The phase split isn't just tidy; the type checker enforces it.

src/agent.ts · pattern mirrors the Alchemy v2 tutorial's "Effect returning an Effect"

04The agent turn itself

respond is the runtime Effect — one prompt in, one answer out. It calls effect/unstable/ai's LanguageModel.generateText({ prompt, toolkit }), which is single-round: one call generates and auto-resolves the tools the model asked for — but it does not loop back to the model on its own. The API ships no maxTurns knob, so the multi-turn loop is ours. We write it as a tail-recursive Effect (no mutable let): each turn returns the model's text, or — if it only called tools — re-prompts with the prior response parts, until turnsLeft runs out.

const generateAnswer = (
  prompt: Prompt.Prompt,
  turnsLeft: number,
): Effect.Effect<string, AiError.AiError, LanguageModel.LanguageModel | ToolHandlers> =>
  LanguageModel.generateText({ prompt, toolkit: SearchTools }).pipe(
    Effect.flatMap((response) =>
      response.text.trim().length > 0
        ? Effect.succeed(response.text)                                  // model answered
        : response.toolCalls.length === 0 || turnsLeft <= 1
          ? Effect.succeed(NO_ANSWER)
          : generateAnswer(                                             // feed results back, ask again
              Prompt.concat(prompt, Prompt.fromResponseParts(response.content)),
              turnsLeft - 1,
            ),
    ),
  );

export const respond = (prompt: string): Effect.Effect<string, never, LanguageModel.LanguageModel | ToolHandlers> =>
  generateAnswer(Prompt.make([{ role: "system", content: SYSTEM }, { role: "user", content: prompt }]), MAX_TURNS).pipe(
    // generateText can fail with AiError; catch at the edge so the HTTP layer always
    // returns a body — a 200 with a graceful message, not a crash.
    Effect.catch((e) => Effect.succeed(`The model call failed: ${...}`)),
  );
Where the loop comes from

The standalone @effect/ai package is effect@3-only and can't live in this effect@4 stack — but effect 4 ships effect/unstable/ai (LanguageModel, Tool, Toolkit, Prompt, Response), which the course uses. generateText({ toolkit }) owns the generate → resolve-tools mechanics for one round; since the API has no maxTurns, respond owns the multi-turn loop (the tail-recursive generateAnswer) and the error boundary. Lesson 08 wraps the turn in one span; the AI module emits the model/tool child spans.

05Serving on 0.0.0.0:8080

Two more steps. First, turn the API + handlers + a model into a single layer. Locally we provide the StubModel (no AWS, deterministic); lesson 03 swaps in BedrockModel.layer(...) — same line, different model.

export const AgentApiLayer = HttpApiBuilder.layer(AgentApi).pipe(
  Layer.provide(AgentHandlers.pipe(Layer.provide(StubModel))),
);

Then serve it. HttpRouter.serve turns the API layer into a running router; NodeHttpServer.layer binds the socket — and binding host: "0.0.0.0" on port 8080 is exactly what the AgentCore contract demands.

import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
import { HttpRouter } from "effect/unstable/http";
import { createServer } from "node:http";

const ServerLive = AgentApiLayer.pipe(
  HttpRouter.serve,
  Layer.provide(NodeHttpServer.layer(createServer, { port: 8080, host: "0.0.0.0" })),
);

if (import.meta.main) {
  Layer.launch(ServerLive).pipe(NodeRuntime.runMain());
}

The import.meta.main guard means tsx src/agent.ts launches the server, but importing the module from a test does not — so the lesson tests can call respond directly without a socket appearing.

serve wiring from effect-http-api/server.ts

06Run it

pnpm dev starts the watcher. The log line you want to see is the bind on 0.0.0.0:8080 — that is the container becoming an AgentCore-shaped server:

$ pnpm dev
[INFO] Listening on http://0.0.0.0:8080

$ curl -s localhost:8080/ping
{"status":"Healthy"}

$ curl -s -X POST localhost:8080/invocations \
    -H 'content-type: application/json' \
    -d '{"prompt":"what is the runtime contract?"}'
{"response":"「stub」 I read the explainers and here is what I found about: what is the runtime contract?"}
Aha

That POST already ran a complete agent turn against the real keyword search over the bundled explainer corpus — the stub model asked for searchExplainers, our loop ran it, and the model summarised the hits. No AWS, no Bedrock, no credentials. The container is contract-complete before we touch the cloud — which is exactly why lessons 03–08 can each be a few lines added to a thing that already works.

This is the lesson's milestone: runs locally. The same server, in a container, is what AgentCore Runtime will host in lesson 05.