Imaginate - Agent-Oriented Docs, Boundary Linting, and a Decoupled Harness

April 26, 2026

Overview

The first version of Imaginate was a Next.js app with the agent loop wired directly into an Inngest function. Everything — the model client, the E2B sandbox, the Prisma writes — lived inside one big handler. That worked while there was one delivery mechanism, but the moment I wanted to iterate on the agent without booting Next, tRPC, and the Inngest dev server, the design fell apart.

This post is about the rewrite. The agent runtime is now a hexagonal core under src/agent/ with explicit ports for every external dependency. The same core powers the production web app and a npm run agent:local CLI (which will grow into a full Ink TUI). The architecture is documented as a contract under docs/, written for AI agents working in the repo, and the contract is enforced by a eslint-plugin-boundaries configuration that mirrors the dependency graph one-to-one.

Explore the source code on GitHub.

Architecture

Agent-oriented documentation under docs/

The docs/ tree is not a wiki and not a changelog — it's operational context for any agent (Claude, Codex, etc.) working in the repo. The root AGENTS.md is the entrypoint. It points to subfolder guides that describe architecture, code style, testing, plans, and research, and it tells the agent which docs to load before each kind of task:

docs/
  architecture/   How src/ is organized. Folder shape, dependency direction, where new code goes.
  code-style/     Project-wide style rules a linter/formatter doesn't enforce.
  testing/        Opinionated testing criteria, test shape, and verification expectations.
  plans/          Planning docs (open/, drift/, archive/) for work spanning >1 PR.
  research/       Agent-oriented research notes from discussions that may matter later but are not plans.
  documentation/  Long-form references (e.g. harness-engineering notes).

Every directory has its own AGENTS.md. Every AGENTS.md has a sibling CLAUDE.md symlink, so Claude Code's auto-load resolves to the same file without drift. The root guide also has a per-task table (Writing or editing code in src/, Creating or editing a plan, Reviewing a PR) that lists the docs the agent has to load first. Slash commands like /plan, /simplify, /review, and /plans-audit each have their own preflight context. This turns "what should the agent read?" from a guess into a lookup.

Architecture contract in one file

docs/architecture/architecture.md is the contract for src/. It names the layers, fixes import direction, and tells contributors where new code goes. It is explicitly described as a contract, not a changelog: feature PRs conform to it, and architecture-changing PRs must update both the doc and the lint rules in the same change set:

src/
  app/          Next.js App Router routes, layouts, route handlers
  interfaces/   Delivery mechanisms: tRPC, Inngest, CLI/scripts, HTTP adapters
  agent/        First-class reusable agent runtime
  features/     Product workflows and UI-facing feature composition
  platform/     Concrete infrastructure adapters shared across runtimes
  ui/           Cross-feature presentation primitives and hooks
  shared/       Small framework-neutral utilities, schemas, test support
  generated/    Generated clients; never edited directly

The doc spells out the dependency graph as ASCII arrows ("agent/domain -> shared", "interfaces -> agent + features + platform + …") and lists the invariants in plain English: agent/domain is pure, agent/application reaches the world only through ports, platform/** cannot import interfaces/** except for one carefully justified exception. Every section ends with a "where to put new code" table mapping a kind of change to a folder. An agent reading this can place new code without inventing a layer.

Boundary linting that mirrors the contract

eslint-plugin-boundaries turns the doc into a runtime check. Each layer is declared as an "element type" with a path pattern, and the allow-list mirrors the dependency graph one-to-one. If someone (or an agent) imports across a forbidden boundary — app/ reaching into agent/adapters/, agent/domain/ pulling in Prisma — ESLint fails the commit:

const boundaryElements = [
  { type: "agent-domain", pattern: "src/agent/domain/**" },
  { type: "agent-application", pattern: "src/agent/application/**" },
  { type: "agent-ports", pattern: "src/agent/ports/**" },
  { type: "agent-adapters", pattern: "src/agent/adapters/**" },
  { type: "interfaces", pattern: "src/interfaces/**" },
  { type: "features", pattern: "src/features/**" },
  { type: "platform-trpc-client", pattern: "src/platform/trpc-client/**" },
  { type: "platform", pattern: "src/platform/**" },
  { type: "shared", pattern: "src/shared/**" },
  // ...
];

const elementRules = [
  { from: { type: "agent-domain" }, allow: to("agent-domain", "shared") },
  {
    from: { type: "agent-application" },
    allow: to("agent-application", "agent-domain", "agent-ports", "shared"),
  },
  {
    from: { type: "agent-adapters" },
    allow: to(
      "agent-adapters",
      "agent-ports",
      "agent-domain",
      "platform",
      "shared",
      "generated",
    ),
  },
  // ...
];

A more specific platform-trpc-client element is declared before the generic platform element so the plugin matches it first. That sub-element is the only point in the codebase where platform interfaces is allowed, because the typed React tRPC client needs to type-import AppRouter from the router definitions. Encoding that single exception in the lint config is far better than relying on humans to remember it.

The error message names the architecture doc directly:

rules: {
  "boundaries/dependencies": ["error", {
    default: "disallow",
    message:
      "Import from '{{to.type}}' is not allowed in '{{from.type}}'. " +
      "See docs/architecture/architecture.md.",
    rules: elementRules,
  }],
},

So the lint output is also a documentation breadcrumb: when an agent's PR fails the boundary check, the failure message tells it where to read.

The agent runtime as a hexagonal core

src/agent/ is internally layered into domain/, application/, ports/, adapters/, and testing/. The rules above guarantee that application/ only ever reaches the world through ports/. Every external concern — model providers, sandboxes, message persistence, telemetry, structured event emission, logging — is a port:

export interface AgentRuntimeDeps {
  modelGateway: ModelGateway;
  sandboxGateway: SandboxGateway;
  toolFactory: ToolFactory;
  messageStore: MessageStore;
  telemetryStore: TelemetryStore;
  eventSink: AgentEventSink;
  logger: AgentLogger;
}

runAgent accepts a deps: AgentRuntimeDeps and a config object with the planner system prompt, the executor system prompt builder, and provider cache options. It does the planning step, walks the model fallback ladder, runs the executor loop, and returns a typed AgentRunResult. It does not import the AI SDK, E2B, Prisma, Inngest, or Next directly — those are all reached through ports. The public surface is src/agent/index.ts; consumers import from @/agent, never from deep paths.

Decoupling the harness: same core, two delivery mechanisms

The decoupling pays off when you compose the same core for two completely different runtimes.

Production (Inngest function inside Next.js): the interface adapter wires up the Prisma-backed message and telemetry stores, the AI SDK gateway, the E2B gateway, and an event sink that converts each runtime event into a structured log line and (for finished steps) a thought row written back to the assistant message. The handler is just composition — the loop logic is runAgent.

Local (CLI): the same runAgent is called from src/interfaces/cli/agent-local.ts with adapters appropriate for terminal use. The Prisma stores are swapped for in-memory ones, the structured logger is replaced with a noop or pretty pino, and the event sink prints events to stdout (text or JSONL):

const sandboxGateway = createE2bSandboxGateway({ sandboxId });
const eventSink = {
  emit: (event: AgentRuntimeEvent) => {
    printEvent(event, args.json);
    if (event.type === AgentRuntimeEventType.PlannerFinished) {
      printPlan(event.plan, args.json);
    }
  },
};

const deps = {
  modelGateway: createAiSdkModelGateway(),
  sandboxGateway,
  toolFactory: createAiSdkToolFactory(),
  messageStore: createInMemoryMessageStore(),
  telemetryStore: createNoopTelemetryStore(),
  eventSink,
  logger: loggerToAgentLogger(log),
};

const result = await runAgent({
  input: { prompt: args.prompt, projectId: "local" },
  deps,
  config: {
    plannerSystemPrompt: PLANNER_PROMPT,
    buildExecutorSystemPrompt,
    providerCacheOptions: CACHE_PROVIDER_OPTIONS,
  },
});

The CLI is described in the architecture doc as a first-class delivery mechanism, not a dev-only script. It exists so agent changes can be developed, debugged, and iterated without booting Next, the tRPC route, or the Inngest dev server, and it must keep parity with the web/Inngest path on runtime events, final output, verification rows, files written, token usage, sandbox URL, and the follow-up command. That parity is only possible because both interfaces consume the same runAgent API — there is no second copy of the orchestration to drift.

This is the foundation for a richer terminal experience: the current CLI is a thin cac-based command that prints events as text or JSONL, but the same event sink contract feeds straight into a future Ink-based TUI. Replacing the printer with an Ink renderer doesn't touch @/agent at all.

Runtime events as the cross-runtime contract

The contract that lets the CLI and the web app share a core is a single discriminated-union event type emitted by runAgent through AgentEventSink.emit. The web app turns events into structured log lines and assistant-message thought rows; the CLI turns the same events into stdout lines or JSONL records:

function formatEvent(event: AgentRuntimeEvent): string {
  switch (event.type) {
    case AgentRuntimeEventType.PlannerFinished:
      return [
        event.type,
        `taskType=${event.plan.taskType}`,
        `requiresCoding=${event.plan.requiresCoding}`,
        `verification=${event.plan.verification}`,
        `targetFiles=${formatList(event.plan.targetFiles)}`,
      ].join(" ");
    case AgentRuntimeEventType.ExecutorAttemptStarted:
      return [
        event.type,
        `attempt=${event.attempt}`,
        `model=${event.model}`,
      ].join(" ");
    case AgentRuntimeEventType.AgentFinished:
      return [
        event.type,
        `status=${event.finalOutput?.status ?? "missing"}`,
        `steps=${event.stepsCount}`,
        `totalTokens=${event.usage.totalTokens}`,
      ].join(" ");
    // ...
  }
}

Because the event types live in agent/domain/events.ts (pure, no I/O), they can be referenced from agent/ports/event-sink.ts without violating the ports domain rule, and they can be consumed from any interface adapter without leaking transport concerns into the agent core.

Difficult Parts

Designing the lint allow-list to match the doc one-to-one

The point of the boundaries config is that it expresses the same graph as the architecture doc. That sounds easy until the codebase has legitimate exceptions. The typed tRPC React client needs AppRouter from interfaces/trpc/routers/, which would normally be a forbidden platform interfaces import. The fix was to carve out src/platform/trpc-client/ as its own element, declared before the generic platform/ pattern so the matcher classifies it first, and document the exception in both the architecture doc and an inline comment in eslint.config.mjs. Every exception had to be a named element with a single, justifiable allow-list — not a blanket relaxation of the rule.

Drawing the line between domain, application, and ports

Putting the runtime event union in the right layer was non-obvious. Events feel like an application-layer concern (they're produced as the run progresses), but the event sink port has to reference their types — and a ports application import would be backwards. The resolution was to keep event types in agent/domain/events.ts (pure data, no behavior) and event production in agent/application/. Now ports/event-sink.ts can import type { AgentRuntimeEvent } from "../domain/events" legally, and the discriminated union becomes a stable, transport-neutral contract that any interface adapter can consume.

Keeping CLI parity with the production path

The CLI is only useful if it actually exercises the same code path the web app does. That means using the production model adapters (not stubs), the same provider cache options, the same planner and executor system prompts, the same fallback ladder. The temptation to "just inline a quick local loop" had to be resisted every time — the CLI imports runAgent from @/agent and supplies real adapters where it can (E2B, AI SDK) and in-memory adapters only where persistence would be wrong (no Prisma writes for a local prompt). The architecture doc explicitly forbids the CLI from owning agent logic; argument parsing, output formatting, and follow-up command generation are CLI helpers, but the loop itself lives behind @/agent.

Making the architecture doc legible to agents, not just humans

A doc that lists the dependency graph as prose is not enough — agents need a lookup. So the architecture doc has an explicit "Where to put new code" table mapping kinds of changes to folders, the root AGENTS.md has a "Always-load context" table mapping tasks to docs, and every slash command has its own preflight list. The lint error message points back to the doc by name. The combination is what makes it usable: the doc states the rule, the lint enforces it, and the error message tells the next agent where to read when the rule bites.

Explore the source code on GitHub.