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.
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
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.
This is the most important idea in the lesson, and it recurs in every deploy lesson. A server has two timescales:
POST /invocations.
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 })),
),
);
}),
);
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"
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: ${...}`)),
);
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.
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
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?"}
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.