AI agents are easy to prototype. Making them reliable enough for real engineering workflows is a different problem.
The Agent Is the Easy Part
I've spent quite a bit of time working with AI-assisted development lately. One thing I've noticed is how quickly you can put together an impressive demo. Give a model a few tools, ask it to perform a task, and suddenly you have an agent that appears to understand your entire workflow.
But connecting an LLM to an API isn't the difficult part. The difficult part is deciding what happens when that API is unavailable, the response doesn't match your expectations, or the agent makes an incorrect assumption.
I work primarily with TypeScript and have used Effect.ts extensively for building backends. That got me thinking: what if we treated an agent's tools like ordinary production services, with explicit dependencies, validated data, and predictable failures?
Let's build a small example.
What We're Building
Imagine an internal assistant that helps developers plan work from engineering tickets. You ask it to prepare a plan for ENG-142. It should retrieve the ticket, read the relevant engineering guidelines, and return a proposed implementation plan with tests and open questions.
It should not modify the codebase, update tickets, or deploy anything. This is a planning assistant, so read-only tools are enough.
We'll use three pieces:
- Effect.ts for services, typed errors, dependency injection, and validation.
- Zod and the AI SDK to describe the tool interface exposed to the model.
- A small context service to provide knowledge about how our team builds software.
The AI SDK handles model interaction and tool calling. Effect handles the engineering work behind the tools.
1. Don't Trust External Data
Let's start with the ticket. Even if we trust our project-management system, its API response is still external data. We should validate it before the rest of our application relies on it.
import { Schema } from "effect";
const Ticket = Schema.Struct({
id: Schema.String,
title: Schema.String,
description: Schema.String,
area: Schema.Literal("backend", "frontend"),
});
type Ticket = typeof Ticket.Type;
Now the rest of the application can work with a known shape instead of passing arbitrary JSON into a prompt.
In a real integration, I'd also validate identifiers, normalize optional fields, and decide what the agent is allowed to see. More data isn't automatically better context, especially when privacy and compliance matter.
2. Model Failures Instead of Hiding Them
Now we need a service that retrieves a ticket. Effect makes it possible to express the failure modes in the type signature.
import { Context, Data, Effect } from "effect";
class TicketNotFound extends Data.TaggedError("TicketNotFound")<{
id: string;
}> {}
class InvalidTicket extends Data.TaggedError("InvalidTicket")<{
id: string;
}> {}
class TicketService extends Context.Tag("TicketService")<
TicketService,
{
get: (
id: string,
) => Effect.Effect<Ticket, TicketNotFound | InvalidTicket>;
}
>() {}
This is one of the things I like most about Effect. The function doesn't just promise a Ticket. It also tells us which failures we expect to handle.
The implementation can call ClickUp, GitHub, or an internal API. The agent doesn't need to know which one. It only depends on the service contract.
That also makes the system easier to test. We can replace the real adapter with a fake one and verify the workflow without making network requests or spending tokens.
3. Context Is Part of the System
A ticket rarely contains enough information to produce a useful implementation plan. An agent needs to understand the conventions and constraints of the codebase.
For a backend task, that might include how we validate requests, where business logic belongs, and how authorization is enforced. For a frontend task, it might include our design system and testing conventions.
I'd rather make that knowledge available through a service than paste it into every new conversation.
class ContextService extends Context.Tag("ContextService")<
ContextService,
{
forArea: (area: Ticket["area"]) => Effect.Effect<string>;
}
>() {}
const loadPlanningContext = (id: string) =>
Effect.gen(function* () {
const tickets = yield* TicketService;
const context = yield* ContextService;
const ticket = yield* tickets.get(id);
const guidelines = yield* context.forArea(ticket.area);
return { ticket, guidelines };
});
Here, ContextService is intentionally simple. In a larger system it could resolve relevant Markdown documentation, architectural decisions, or domain-specific instructions. The important part is that context retrieval becomes a deliberate, testable capability.
An LLM already knows TypeScript. It doesn't know why your team has chosen a particular way of writing TypeScript.
4. Expose a Small, Typed Tool
With those services in place, we can give the agent one tool: readTicketContext.
import { generateText, stepCountIs, tool } from "ai";
import { Effect } from "effect";
import { z } from "zod";
const result = await generateText({
model: process.env.AI_MODEL!,
system: `You are a planning assistant.
Read the ticket context before writing a plan.
Propose implementation steps, tests, and unknowns.
Never claim that code was changed or tested.`,
prompt: "Prepare an implementation plan for ENG-142.",
stopWhen: stepCountIs(3),
tools: {
readTicketContext: tool({
description: "Read a ticket and its engineering guidelines",
inputSchema: z.object({
id: z.string().regex(/^ENG-\d+$/),
}),
execute: async ({ id }) =>
Effect.runPromise(
loadPlanningContext(id).pipe(
Effect.map((data) => ({ ok: true as const, data })),
Effect.catchTags({
TicketNotFound: () =>
Effect.succeed({ ok: false as const, error: "Not found" }),
InvalidTicket: () =>
Effect.succeed({ ok: false as const, error: "Invalid ticket" }),
}),
Effect.provide(AppLayer),
),
),
}),
},
});
console.log(result.text);
AppLayer is where we provide the concrete implementations of TicketService and ContextService. It might connect to a real API in production and use in-memory data in tests. The code above focuses on the tool boundary, not the adapter setup.
Notice what we're not exposing: arbitrary HTTP requests, shell access, database queries, or tools that mutate tickets. The model can ask for approved context, and the application controls what actually happens.
We're also returning an explicit result when a ticket cannot be loaded. That lets the agent explain the problem instead of quietly making up missing requirements.
5. Test the Deterministic Parts
I wouldn't start by writing tests that ask an LLM to produce the exact same paragraph every time. I'd start with the parts that should behave predictably.
Can we decode the ticket response? Do we return the correct error for a missing ticket? Are backend guidelines selected for a backend ticket? Does the tool prevent unauthorized actions?
Those tests don't need a model. They need controlled services and clear expectations. We can test model behavior separately with representative scenarios and evaluate whether the generated plans are useful.
This separation matters. When something goes wrong, I want to know whether the issue came from the model, our context, an API integration, or a bug in the application.
Where Effect Actually Helps
Could you build this with plain TypeScript, Zod, and try/catch? Absolutely. For a small prototype, that's probably what I'd do.
Effect starts becoming interesting when the system grows: several external APIs, transient failures, retries, timeouts, tracing, shared dependencies, and workflows that need to remain testable. Those are familiar backend problems, even when an LLM happens to be involved.
It doesn't make the model deterministic. It helps make everything surrounding the model more explicit and predictable.
That's the part I care about.
From Agent Demos to Software Factories
In the earlier articles in my Software Factory Era series, I wrote about moving from AI-assisted coding toward engineering environments where agents can carry out larger parts of the development process.
This is a small example of what that foundation can look like. A single tool, a few services, explicit boundaries, and context that belongs to the team rather than to one developer's chat history.
We can expand it later with additional tools, approval workflows, better context retrieval, and automated verification. But I think the right starting point is to build one reliable capability and understand its limits before adding more autonomy.
The model is the unpredictable part. Everything around it should be engineered accordingly.
Top comments (0)