Skip to content

0011. Inject the assistant's tool executor at boot

Context

The assistant's loop lives in the @monark/chat package. To run a tool it needs the app's tRPC router and the public route descriptors, and both live in services/api. Packages never import the api service: dependencies point from services/api to packages, never back, and importing the router from a package would create a cycle. So the package that owns the loop can't build its own tools.

Options

OptionForAgainst
Import the router from the chat packageEverything in one placeA dependency cycle; breaks the layering rule every other package follows
Move the loop into services/apiNo seam neededThe conversation module stops being reusable; the loop can't be tested without the api
Define a contract in the package, inject the executor at bootDependencies stay one-way; the package is testable with a fakeTools are assembled away from the loop; a host that forgets to wire it fails at run time

Decision

@monark/chat defines the contract, ChatToolExecutor: list the tool specs, look one up by name, and run a call as a given user (never throwing; a failure is a result). The api implements it in buildChatToolset() and wires it before serving any request:

// services/api/src/server.ts
setChatToolExecutor(buildChatToolset());

If nothing is wired, the first chat turn fails with chat_tools_not_wired (503) instead of running an agent with no tools. This is the same host-injection seam as setWebhookSecretResolver.

Consequences

  • The dependency graph stays one-way, and @monark/chat stays usable by another host.
  • The package's tests drive the loop with a fake executor, without the api.
  • New tools go in services/api/src/chat/, not in the package that owns the loop.
  • The executor is set once for the whole process. Every user sees the same tool list; what each person can actually do is decided by their permissions at call time.